mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 13:49:15 +08:00
Compare commits
14 Commits
feat/plugi
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7521de42f4 | ||
|
|
02e56da0fc | ||
|
|
5e3c5baf82 | ||
|
|
b5f8996ccc | ||
|
|
714183530b | ||
|
|
ab98818e5b | ||
|
|
d66bac5a1a | ||
|
|
300371c3f2 | ||
|
|
f4531feee8 | ||
|
|
6d2732e786 | ||
|
|
aa424e51ac | ||
|
|
732ababa1a | ||
|
|
421226e404 | ||
|
|
37561c214b |
@@ -10,6 +10,7 @@ import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { TerminalSlot } from '@/app/right-sidebar/terminal/persistent'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
@@ -388,6 +389,9 @@ export function ChatView({
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
<section className="flex h-56 min-h-[10rem] shrink-0 border-t border-(--ui-stroke-secondary) bg-(--ui-editor-surface-background)">
|
||||
<TerminalSlot />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
@@ -84,9 +84,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
}
|
||||
}
|
||||
|
||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
||||
const showFiles = () => {
|
||||
setFileBrowserOpen(true)
|
||||
setRightSidebarTab(tab)
|
||||
setTerminalTakeover(false)
|
||||
}
|
||||
|
||||
handlersRef.current = {
|
||||
@@ -128,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(true),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
||||
import { TerminalSlot } from './terminal/persistent'
|
||||
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
interface RightSidebarTab {
|
||||
icon: string
|
||||
id: RightSidebarTabId
|
||||
labelKey: 'files' | 'terminal'
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
||||
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={r.aria}
|
||||
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
{effectiveTab === 'terminal' ? (
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
)}
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RightSidebarChrome({
|
||||
activeTab,
|
||||
branch,
|
||||
tabs
|
||||
}: {
|
||||
activeTab: RightSidebarTabId
|
||||
branch: string
|
||||
tabs: readonly RightSidebarTab[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => {
|
||||
const label = r[tab.labelKey]
|
||||
|
||||
return (
|
||||
<Tip key={tab.id} label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
|
||||
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
||||
|
||||
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
||||
|
||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
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 { useI18n } from '@/i18n'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '../store'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { addSelectionShortcutLabel, terminalTheme } from './selection'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
|
||||
interface TerminalTabProps {
|
||||
@@ -21,6 +23,9 @@ interface TerminalTabProps {
|
||||
|
||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const { t } = useI18n()
|
||||
const { resolvedMode } = useTheme()
|
||||
const theme = terminalTheme(resolvedMode)
|
||||
|
||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||
cwd,
|
||||
onAddSelectionToChat
|
||||
@@ -30,22 +35,17 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
|
||||
|
||||
const toggleTakeover = () => {
|
||||
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
||||
if (takeover) {
|
||||
setRightSidebarTab('terminal')
|
||||
}
|
||||
|
||||
setTerminalTakeover(!takeover)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -55,7 +55,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
<div className="relative min-h-0 flex-1 p-2" style={{ backgroundColor: theme.background }}>
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader
|
||||
@@ -84,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
||||
through the unused pixels below the last full row. */}
|
||||
{/* Outer div paints terminal inset; inner div is the xterm host so the
|
||||
canvas sizes to the content area and p-2 stays as terminal padding. */}
|
||||
<div
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[var(--terminal-bg)]! [&_.xterm-viewport]:bg-[var(--terminal-bg)]!"
|
||||
ref={hostRef}
|
||||
style={{ '--terminal-bg': theme.background } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TERMINAL_BG } from './selection'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { terminalTheme } from './selection'
|
||||
|
||||
import { TerminalTab } from './index'
|
||||
|
||||
@@ -56,6 +58,8 @@ const sameRect = (a: Rect | null, b: Rect) =>
|
||||
|
||||
export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) {
|
||||
const slot = useStore($slot)
|
||||
const { resolvedMode } = useTheme()
|
||||
const theme = terminalTheme(resolvedMode)
|
||||
const [rect, setRect] = useState<Rect | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
@@ -107,7 +111,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
zIndex: 4,
|
||||
backgroundColor: TERMINAL_BG,
|
||||
backgroundColor: theme.background,
|
||||
contain: 'layout size paint'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
|
||||
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
|
||||
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
|
||||
// We always render the dark canvas — the app's light surfaces can't host the
|
||||
// default skin without dropping below readable contrast.
|
||||
export const TERMINAL_BG = '#002b36'
|
||||
|
||||
const THEME: ITheme = {
|
||||
background: TERMINAL_BG,
|
||||
foreground: '#839496',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: TERMINAL_BG,
|
||||
selectionBackground: '#586e7555',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#586e75',
|
||||
brightRed: '#f25c54',
|
||||
brightGreen: '#b3d437',
|
||||
brightYellow: '#f7c948',
|
||||
brightBlue: '#5fb3ff',
|
||||
brightMagenta: '#ff6ab4',
|
||||
brightCyan: '#5cd9c8',
|
||||
brightWhite: '#fdf6e3'
|
||||
const DARK_THEME: ITheme = {
|
||||
background: '#0f172a',
|
||||
foreground: '#dbe4ff',
|
||||
cursor: '#f8fafc',
|
||||
cursorAccent: '#0f172a',
|
||||
selectionBackground: '#93c5fd55',
|
||||
black: '#1e293b',
|
||||
red: '#f87171',
|
||||
green: '#4ade80',
|
||||
yellow: '#facc15',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e2e8f0',
|
||||
brightBlack: '#64748b',
|
||||
brightRed: '#fca5a5',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde047',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#f8fafc'
|
||||
}
|
||||
|
||||
export const terminalTheme = (): ITheme => THEME
|
||||
const LIGHT_THEME: ITheme = {
|
||||
background: '#f8fafc',
|
||||
foreground: '#1f2937',
|
||||
cursor: '#111827',
|
||||
cursorAccent: '#f8fafc',
|
||||
selectionBackground: '#60a5fa44',
|
||||
black: '#1f2937',
|
||||
red: '#dc2626',
|
||||
green: '#15803d',
|
||||
yellow: '#a16207',
|
||||
blue: '#1d4ed8',
|
||||
magenta: '#9333ea',
|
||||
cyan: '#0e7490',
|
||||
white: '#d1d5db',
|
||||
brightBlack: '#4b5563',
|
||||
brightRed: '#ef4444',
|
||||
brightGreen: '#22c55e',
|
||||
brightYellow: '#eab308',
|
||||
brightBlue: '#3b82f6',
|
||||
brightMagenta: '#a855f7',
|
||||
brightCyan: '#06b6d4',
|
||||
brightWhite: '#111827'
|
||||
}
|
||||
|
||||
export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME)
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
|
||||
|
||||
@@ -184,6 +185,9 @@ function quotePathForShell(path: string, shellName: string): string {
|
||||
}
|
||||
|
||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
||||
const { resolvedMode } = useTheme()
|
||||
const activeTheme = useMemo(() => terminalTheme(resolvedMode), [resolvedMode])
|
||||
const initialThemeRef = useRef(activeTheme)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(null)
|
||||
@@ -266,7 +270,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
lineHeight: 1.12,
|
||||
macOptionIsMeta: true,
|
||||
scrollback: 1000,
|
||||
theme: terminalTheme()
|
||||
theme: initialThemeRef.current
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
@@ -493,6 +497,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
}
|
||||
}, [addSelectionToChat, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
|
||||
if (term) {
|
||||
term.options.theme = activeTheme
|
||||
}
|
||||
}, [activeTheme])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
@@ -781,9 +782,7 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
|
||||
257
apps/desktop/src/lib/completion-sound.ts
Normal file
257
apps/desktop/src/lib/completion-sound.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
// Completion sound bank for agent turn-end cues.
|
||||
// Runtime playback is pinned to a curated default (currently variant 8).
|
||||
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
type OscType = OscillatorType
|
||||
|
||||
interface ToneSpec {
|
||||
attack?: number
|
||||
dur: number
|
||||
freq: number
|
||||
gain?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
|
||||
if (!Ctor) {
|
||||
return null
|
||||
}
|
||||
|
||||
ctx = new Ctor()
|
||||
}
|
||||
|
||||
// Autoplay policies can leave the context suspended until a gesture; a
|
||||
// resume() here recovers it once the user has interacted with the window.
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => undefined)
|
||||
}
|
||||
|
||||
return ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// One enveloped oscillator voice → master. Linear attack into an exponential
|
||||
// decay keeps the tail smooth and avoids the click you get ramping to zero.
|
||||
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const peak = spec.gain ?? 0.5
|
||||
const attack = spec.attack ?? 0.006
|
||||
const end = start + spec.dur
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
let reverbImpulse: AudioBuffer | null = null
|
||||
|
||||
// A short, synthetic reverb tail (decaying noise impulse). Used as a subtle wet
|
||||
// send so the chimes feel like they sit in a room rather than a tin can — the
|
||||
// detail that nudges them from "arcade beep" toward "polished app". The impulse
|
||||
// buffer is generated once and cached; each play gets a fresh, disposable
|
||||
// convolver so connections never pile up on a shared node.
|
||||
function makeReverb(ac: AudioContext): ConvolverNode {
|
||||
if (!reverbImpulse) {
|
||||
const seconds = 1.1
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
|
||||
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = reverbImpulse.getChannelData(channel)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
// White noise with a steep exponential decay → smooth, short tail.
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convolver = ac.createConvolver()
|
||||
convolver.buffer = reverbImpulse
|
||||
|
||||
return convolver
|
||||
}
|
||||
|
||||
export interface CompletionSoundVariant {
|
||||
id: number
|
||||
name: string
|
||||
// `master` is warm (runs through low-pass + room tail).
|
||||
play: (ac: AudioContext, master: GainNode, t0: number) => void
|
||||
}
|
||||
|
||||
// Note frequencies (equal temperament). Everything lives in a low-mid register
|
||||
// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
|
||||
const C3 = 130.81
|
||||
const C4 = 261.63
|
||||
const E4 = 329.63
|
||||
const G4 = 392
|
||||
const C5 = 523.25
|
||||
const D5 = 587.33
|
||||
const E5 = 659.25
|
||||
const G5 = 783.99
|
||||
const C6 = 1046.5
|
||||
|
||||
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tiks success (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from rexa-developer/tiks success(): tonic then fifth.
|
||||
voice(ac, master, t0, { freq: C5, dur: 0.11, gain: 0.12, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.085, { freq: G5, dur: 0.16, gain: 0.12, attack: 0.008, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Seslen message (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen message(): soft two-tone bell.
|
||||
voice(ac, master, t0, { freq: 880, dur: 0.28, gain: 0.1, attack: 0.01, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: 1320, dur: 0.34, gain: 0.085, attack: 0.01, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Seslen success chirp (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen success(): 660→990→1320 triangle.
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(660, t0)
|
||||
osc.frequency.linearRampToValueAtTime(990, t0 + 0.08)
|
||||
osc.frequency.linearRampToValueAtTime(1320, t0 + 0.18)
|
||||
env.gain.setValueAtTime(0.0001, t0)
|
||||
env.gain.linearRampToValueAtTime(0.11, t0 + 0.01)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.32)
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(t0)
|
||||
osc.stop(t0 + 0.34)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Tiks notify (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from rexa-developer/tiks notify(): two-note rising figure.
|
||||
voice(ac, master, t0, { freq: 880, dur: 0.18, gain: 0.1, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.1, { freq: 1320, dur: 0.3, gain: 0.1, attack: 0.008, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Seslen notify (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
// Ported from productdevbook/seslen notify(): 660-880-1320 sequence.
|
||||
const notes = [660, 880, 1320]
|
||||
notes.forEach((frequency, i) => {
|
||||
const start = t0 + i * 0.1
|
||||
voice(ac, master, start, { freq: frequency, dur: 0.16, gain: 0.095, attack: 0.01, type: 'sine' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Seslen victory arpeggio (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, E5, G5, C6]
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.24, gain: 0.12, attack: 0.008, type: 'triangle' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Seslen level-up arpeggio (MIT)',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, D5, E5, G5, C6]
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.09, { freq: frequency, dur: 0.22, gain: 0.11, attack: 0.008, type: 'triangle' })
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Two-note comfort (minimal)',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
const DEFAULT_COMPLETION_VARIANT_ID = 8
|
||||
|
||||
function playVariant(variantId: number) {
|
||||
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
|
||||
|
||||
if (!variant) {
|
||||
return
|
||||
}
|
||||
|
||||
const ac = getCtx()
|
||||
|
||||
if (!ac) {
|
||||
return
|
||||
}
|
||||
|
||||
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
|
||||
// The low-pass sits low to keep things warm, and a small wet send adds the
|
||||
// sense of space that makes the chime feel like part of a polished app.
|
||||
const master = ac.createGain()
|
||||
const tone = ac.createBiquadFilter()
|
||||
tone.type = 'lowpass'
|
||||
tone.frequency.setValueAtTime(3400, ac.currentTime)
|
||||
tone.Q.setValueAtTime(0.4, ac.currentTime)
|
||||
master.gain.setValueAtTime(0.7, ac.currentTime)
|
||||
master.connect(tone)
|
||||
|
||||
const dry = ac.createGain()
|
||||
dry.gain.setValueAtTime(0.92, ac.currentTime)
|
||||
tone.connect(dry)
|
||||
dry.connect(ac.destination)
|
||||
|
||||
const reverb = makeReverb(ac)
|
||||
const wet = ac.createGain()
|
||||
wet.gain.setValueAtTime(0.22, ac.currentTime)
|
||||
tone.connect(reverb)
|
||||
reverb.connect(wet)
|
||||
wet.connect(ac.destination)
|
||||
|
||||
variant.play(ac, master, ac.currentTime + 0.01)
|
||||
}
|
||||
|
||||
// Plays the fixed completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant(DEFAULT_COMPLETION_VARIANT_ID)
|
||||
}
|
||||
68
cli.py
68
cli.py
@@ -6218,27 +6218,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
choices visible and lets the normal Enter key binding submit the typed
|
||||
or highlighted choice.
|
||||
|
||||
**Platform note (Windows dead-lock — issue #30768):**
|
||||
The queue-based modal relies on prompt_toolkit key bindings receiving
|
||||
keyboard events and calling ``_submit_slash_confirm_response``. On
|
||||
Windows (PowerShell / Windows Terminal) the prompt_toolkit input
|
||||
channel can become unresponsive when the modal is entered from the
|
||||
``process_loop`` daemon thread, causing a dead-lock: the user sees the
|
||||
confirmation panel but keystrokes never reach the key bindings and the
|
||||
``response_queue.get()`` blocks until the 120-second timeout expires.
|
||||
**Platform note (Windows — issue #33961):**
|
||||
Earlier code bypassed the modal on ``sys.platform == "win32"`` and fell
|
||||
back to a raw ``input()`` prompt. When the confirm was triggered from the
|
||||
``process_loop`` daemon thread (the normal case) that ``input()`` ran off
|
||||
the main thread and deadlocked against prompt_toolkit's stdin ownership —
|
||||
the user saw a frozen cursor and Ctrl-C was swallowed (bare ``/reset``
|
||||
froze; ``/reset now`` worked only because it skips the prompt entirely).
|
||||
|
||||
To avoid this, we fall back to ``_prompt_text_input`` (a simple
|
||||
``input()``-based prompt) when any of these conditions hold:
|
||||
|
||||
* ``sys.platform == "win32"`` — native Windows console (ConPTY /
|
||||
win32_input) does not support the modal reliably.
|
||||
* ``self._app`` is not set — unit tests / non-interactive contexts.
|
||||
|
||||
On non-Windows platforms the modal itself is still safe from the
|
||||
``process_loop`` daemon thread as long as the main-thread event loop
|
||||
owns the prompt_toolkit buffer mutations. When we are off the main
|
||||
thread, schedule the modal snapshot / restore work on ``self._app.loop``
|
||||
via ``call_soon_threadsafe`` and keep the queue-based response path.
|
||||
Native Windows now uses the same path as Linux/macOS: the modal is set up
|
||||
on ``self._app.loop`` via ``call_soon_threadsafe`` and answered by the
|
||||
normal prompt_toolkit key bindings (the same input channel that already
|
||||
handles ordinary typing on Windows). The raw ``input()`` fallback is kept
|
||||
only for the genuinely safe cases: no running app (unit tests /
|
||||
non-interactive), no resolvable event loop, or a scheduling failure.
|
||||
"""
|
||||
import threading
|
||||
import time as _time
|
||||
@@ -6251,23 +6244,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if not getattr(self, "_app", None):
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
# On Windows the prompt_toolkit input channel can deadlock when the
|
||||
# modal is entered from the process_loop daemon thread — keystrokes
|
||||
# never reach the key bindings, so response_queue.get() blocks for
|
||||
# the full timeout (issue #30768). Fall back to the simpler
|
||||
# stdin-based prompt which works reliably on Windows.
|
||||
if sys.platform == "win32":
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
try:
|
||||
app_loop = self._app.loop
|
||||
except Exception:
|
||||
app_loop = None
|
||||
|
||||
in_main_thread = threading.current_thread() is threading.main_thread()
|
||||
if not in_main_thread and app_loop is None:
|
||||
|
||||
def _stdin_fallback() -> str | None:
|
||||
# On native Windows a raw input() from a non-main thread deadlocks
|
||||
# against prompt_toolkit's stdin ownership (#33961). With an app
|
||||
# running we cannot safely prompt off the main thread, so cancel
|
||||
# cleanly (None) rather than hang the terminal.
|
||||
if sys.platform == "win32" and not in_main_thread:
|
||||
self._invalidate()
|
||||
return None
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
|
||||
if not in_main_thread and app_loop is None:
|
||||
return _stdin_fallback()
|
||||
|
||||
response_queue = queue.Queue()
|
||||
|
||||
def _setup_modal() -> None:
|
||||
@@ -6307,7 +6303,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
return ready.wait(timeout=5)
|
||||
|
||||
if not _run_on_app_loop(_setup_modal):
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
return _stdin_fallback()
|
||||
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
try:
|
||||
@@ -8223,9 +8219,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
|
||||
|
||||
# Inline-skip tokens that bypass the destructive-slash confirmation modal.
|
||||
# Matches the escape-hatch pattern users on broken modal platforms
|
||||
# (currently native Windows PowerShell — issue #30768) need to self-serve
|
||||
# without having to flip approvals.destructive_slash_confirm in config.
|
||||
# A general escape hatch for non-interactive use (scripting/automation) and
|
||||
# for the degraded path where the modal can't be marshaled onto the app loop
|
||||
# — lets users self-serve without flipping approvals.destructive_slash_confirm
|
||||
# in config. (Native Windows now drives the modal normally — see #33961.)
|
||||
_DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"})
|
||||
|
||||
@classmethod
|
||||
@@ -8283,8 +8280,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or
|
||||
``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),
|
||||
the modal is bypassed and ``"once"`` is returned immediately. This is
|
||||
an escape hatch for platforms where the prompt_toolkit modal hangs
|
||||
(issue #30768 — native Windows PowerShell). Callers are responsible
|
||||
an escape hatch for non-interactive use and for the degraded path where
|
||||
the modal can't be marshaled onto the app loop (native Windows itself now
|
||||
drives the modal normally — see #33961). Callers are responsible
|
||||
for stripping the skip tokens from any remaining argument parsing
|
||||
(see :meth:`_split_destructive_skip`).
|
||||
|
||||
|
||||
@@ -1795,9 +1795,11 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Whether this platform renders triple-backtick fenced code blocks (i.e.
|
||||
# ``format_message`` translates/preserves markdown fences into a real code
|
||||
# block). Drives presentation choices like rendering a ``terminal`` tool
|
||||
# call's command as a ```bash block instead of a flat preview line.
|
||||
# block). Capability flag for markdown-aware presentation choices.
|
||||
# Default False (plain-text platforms); markdown-rendering adapters set True.
|
||||
# Note: tool-progress deliberately does NOT use this to render a terminal
|
||||
# command as a ```bash block — that exposed full commands in chat. Progress
|
||||
# shows a short truncated preview only (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
|
||||
@@ -181,6 +181,8 @@ def _strip_mdv2(text: str) -> str:
|
||||
"""
|
||||
# Remove escape backslashes before special characters
|
||||
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
|
||||
# Remove standard markdown bold (**text** → text) BEFORE MarkdownV2 bold
|
||||
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 bold markers that format_message converted from **bold**
|
||||
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 italic markers that format_message converted from *italic*
|
||||
@@ -2208,11 +2210,17 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# "Message is not modified" is a no-op, not an error
|
||||
if "not modified" in str(fmt_err).lower():
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
# Fallback: retry without markdown formatting
|
||||
# Fallback: strip MarkdownV2 escapes and retry as clean plain text
|
||||
logger.warning(
|
||||
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
|
||||
self.name,
|
||||
fmt_err,
|
||||
)
|
||||
_plain = _strip_mdv2(content) if content else content
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=content,
|
||||
text=_plain,
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as e:
|
||||
|
||||
@@ -12971,32 +12971,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# Build progress message with primary argument preview
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(tool_name, default="⚙️")
|
||||
|
||||
# Markdown-capable platforms render a terminal command as a native
|
||||
# ```bash fenced block (full command, no quotes, no label, no
|
||||
# truncation) instead of the noisy `terminal: "cmd…"` line. Gated
|
||||
# on the adapter's ``supports_code_blocks`` capability so every
|
||||
# markdown-rendering platform (and plugin adapters that opt in) gets
|
||||
# it, while plain-text platforms keep the compact line.
|
||||
_bash_block = None
|
||||
try:
|
||||
_progress_adapter = self.adapters.get(source.platform)
|
||||
except Exception:
|
||||
_progress_adapter = None
|
||||
if (
|
||||
getattr(_progress_adapter, "supports_code_blocks", False)
|
||||
and tool_name == "terminal"
|
||||
and isinstance(args, dict)
|
||||
and isinstance(args.get("command"), str)
|
||||
and args["command"].strip()
|
||||
):
|
||||
_bash_block = f"```bash\n{args['command'].rstrip()}\n```"
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if _bash_block is not None:
|
||||
msg = _bash_block
|
||||
elif args:
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
args_str = json.dumps(args, ensure_ascii=False, default=str)
|
||||
@@ -13016,9 +12994,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# "all" / "new" modes: short preview, respects tool_preview_length
|
||||
# config (defaults to 40 chars when unset to keep gateway messages
|
||||
# compact — unlike CLI spinners, these persist as permanent messages).
|
||||
if _bash_block is not None:
|
||||
msg = _bash_block
|
||||
elif preview:
|
||||
if preview:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
@@ -13130,6 +13106,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"message_id": message_id,
|
||||
"content": content,
|
||||
}
|
||||
if getattr(adapter, "REQUIRES_EDIT_FINALIZE", False):
|
||||
kwargs["finalize"] = True
|
||||
if _edit_accepts_metadata:
|
||||
kwargs["metadata"] = _progress_metadata
|
||||
return await adapter.edit_message(**kwargs)
|
||||
|
||||
@@ -738,11 +738,14 @@ def run_doctor(args):
|
||||
issues,
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them.
|
||||
# Vendor/model slugs are valid on aggregator-style providers and on any custom
|
||||
# provider — bare "custom" or a named "custom:<name>" that fronts an OpenAI-compatible
|
||||
# aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.
|
||||
provider_for_policy = runtime_provider or catalog_provider
|
||||
provider_policy_id = str(provider_for_policy or "").strip().lower()
|
||||
providers_accepting_vendor_slugs = {
|
||||
"openrouter",
|
||||
"custom",
|
||||
"auto",
|
||||
"kilocode",
|
||||
"opencode-zen",
|
||||
@@ -750,11 +753,16 @@ def run_doctor(args):
|
||||
"lmstudio",
|
||||
"nous",
|
||||
}
|
||||
provider_accepts_vendor_slug = (
|
||||
provider_policy_id in providers_accepting_vendor_slugs
|
||||
or provider_policy_id == "custom"
|
||||
or provider_policy_id.startswith("custom:")
|
||||
)
|
||||
if (
|
||||
default_model
|
||||
and "/" in default_model
|
||||
and provider_for_policy
|
||||
and provider_for_policy not in providers_accepting_vendor_slugs
|
||||
and provider_policy_id
|
||||
and not provider_accepts_vendor_slug
|
||||
):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
|
||||
@@ -492,7 +492,10 @@ def get_label(provider_id: str) -> str:
|
||||
|
||||
def is_aggregator(provider: str) -> bool:
|
||||
"""Return True when the provider is a multi-model aggregator."""
|
||||
pdef = get_provider(provider)
|
||||
provider_norm = normalize_provider(provider or "")
|
||||
if provider_norm.startswith("custom:"):
|
||||
return True
|
||||
pdef = get_provider(provider_norm)
|
||||
return pdef.is_aggregator if pdef else False
|
||||
|
||||
|
||||
|
||||
@@ -41,15 +41,19 @@ except ImportError: # pragma: no cover - httpx is a hermes dependency
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PhotonDashboardAuthError(RuntimeError):
|
||||
"""Raised when Photon rejects a device-flow token for the dashboard API."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
# Photon's published OAuth device-client identifier for first-party CLIs.
|
||||
# We use a fixed "hermes-agent" client_id string — Photon's device endpoint
|
||||
# accepts any opaque client_id and ties the bearer token to the approving
|
||||
# user, not to the client. If Photon later requires registered clients,
|
||||
# this is the one knob to update.
|
||||
DEFAULT_CLIENT_ID = "hermes-agent"
|
||||
# Hosted Photon allowlists registered device clients on the device-code
|
||||
# endpoint — an unregistered client_id is rejected with
|
||||
# `400 {"error":"invalid_client"}`. Use Photon's published CLI device
|
||||
# client until the dashboard API registers Hermes as its own client_id.
|
||||
DEFAULT_CLIENT_ID = "photon-cli"
|
||||
DEFAULT_SCOPE = "openid profile email"
|
||||
|
||||
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
|
||||
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
|
||||
@@ -166,6 +170,13 @@ class DeviceCode:
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DeviceTokenCandidate:
|
||||
"""A token-like value extracted from the device-token response."""
|
||||
source: str
|
||||
token: str
|
||||
|
||||
|
||||
def _dashboard_host() -> str:
|
||||
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
|
||||
|
||||
@@ -175,7 +186,7 @@ def _spectrum_host() -> str:
|
||||
|
||||
|
||||
def request_device_code(
|
||||
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = None,
|
||||
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = DEFAULT_SCOPE,
|
||||
) -> DeviceCode:
|
||||
"""POST ``/api/auth/device/code`` and return the device + user codes."""
|
||||
if httpx is None:
|
||||
@@ -232,16 +243,22 @@ def poll_for_token(
|
||||
time.sleep(sleep)
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
token = resp.headers.get("set-auth-token")
|
||||
if not token:
|
||||
body = resp.json() or {}
|
||||
session = body.get("session") or {}
|
||||
token = session.get("access_token") or body.get("access_token")
|
||||
if not token:
|
||||
body: Dict[str, Any] = {}
|
||||
try:
|
||||
decoded = resp.json() or {}
|
||||
body = decoded if isinstance(decoded, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
body = {}
|
||||
candidates = _device_response_token_candidates(
|
||||
body, headers=getattr(resp, "headers", {}),
|
||||
)
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
"Photon returned 200 but no token in headers or body"
|
||||
"Photon returned 200 but no token candidate in the "
|
||||
"device-token response (expected access_token, "
|
||||
"data.access_token, accessToken, or set-auth-token)."
|
||||
)
|
||||
return token
|
||||
return candidates[0].token
|
||||
if resp.status_code == 400:
|
||||
# RFC 8628 §3.5 — error codes are returned with 400.
|
||||
body: Dict[str, Any] = {}
|
||||
@@ -273,6 +290,142 @@ def poll_for_token(
|
||||
raise TimeoutError("Photon device login timed out")
|
||||
|
||||
|
||||
def _device_response_token_candidates(
|
||||
body: Dict[str, Any],
|
||||
*,
|
||||
headers: Optional[Any] = None,
|
||||
) -> list:
|
||||
"""Extract de-duplicated token candidates from a device-token response.
|
||||
|
||||
Photon's device-token endpoint has returned tokens under several keys
|
||||
across versions (``access_token``, ``accessToken``, ``data.*``) and the
|
||||
documented ``set-auth-token`` response header. We collect every shape so
|
||||
the caller can validate each against the dashboard API before trusting it.
|
||||
"""
|
||||
candidates: list = []
|
||||
seen: set = set()
|
||||
|
||||
def add(source: str, value: Any) -> None:
|
||||
token = _clean_bearer_token(value)
|
||||
if not token or token in seen:
|
||||
return
|
||||
seen.add(token)
|
||||
candidates.append(_DeviceTokenCandidate(source=source, token=token))
|
||||
|
||||
add("access_token", body.get("access_token"))
|
||||
add("accessToken", body.get("accessToken"))
|
||||
session = body.get("session")
|
||||
if isinstance(session, dict):
|
||||
add("session.access_token", session.get("access_token"))
|
||||
data = body.get("data")
|
||||
if isinstance(data, dict):
|
||||
add("data.access_token", data.get("access_token"))
|
||||
add("data.accessToken", data.get("accessToken"))
|
||||
add("set-auth-token", _header_value(headers, "set-auth-token"))
|
||||
return candidates
|
||||
|
||||
|
||||
def _clean_bearer_token(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
token = value.strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token[7:].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _header_value(headers: Optional[Any], name: str) -> Optional[str]:
|
||||
if not headers:
|
||||
return None
|
||||
try:
|
||||
value = headers.get(name)
|
||||
if value:
|
||||
return str(value)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
for key, value in dict(headers).items():
|
||||
if str(key).lower() == name.lower() and value:
|
||||
return str(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _dashboard_get(path: str, token: str) -> Any:
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon device login")
|
||||
url = f"{_dashboard_host()}{path}"
|
||||
return httpx.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def validate_photon_token(token: str) -> Dict[str, Any]:
|
||||
"""Verify a device-flow token is usable for dashboard project APIs.
|
||||
|
||||
The device flow can return a token that authenticates the Better Auth
|
||||
session lookup but is rejected by the project APIs. Validate against
|
||||
``/api/auth/get-session`` and ``/api/projects/`` so we fail loudly at
|
||||
login instead of saving a token that 404s/401s downstream.
|
||||
"""
|
||||
resp = _dashboard_get("/api/auth/get-session", token)
|
||||
if resp.status_code in (401, 403):
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon issued a device token, but the dashboard session lookup "
|
||||
"rejected it."
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
user = data.get("user") if isinstance(data, dict) else None
|
||||
if not isinstance(user, dict) or not user:
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon issued a device token, but the dashboard session lookup "
|
||||
"did not recognize it."
|
||||
)
|
||||
projects_resp = _dashboard_get("/api/projects/", token)
|
||||
if projects_resp.status_code in (401, 403):
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon device token was accepted for the session lookup but "
|
||||
"rejected by the project API."
|
||||
)
|
||||
projects_resp.raise_for_status()
|
||||
return user
|
||||
|
||||
|
||||
def _validated_dashboard_token(candidates: list) -> str:
|
||||
"""Return the first candidate token that passes dashboard validation."""
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
"Photon returned 200 but no token candidate in the device-token "
|
||||
"response."
|
||||
)
|
||||
dashboard_error: Optional[PhotonDashboardAuthError] = None
|
||||
last_error: Optional[BaseException] = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
validate_photon_token(candidate.token)
|
||||
return candidate.token
|
||||
except PhotonDashboardAuthError as exc:
|
||||
dashboard_error = exc
|
||||
last_error = exc
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if dashboard_error is not None:
|
||||
sources = ", ".join(c.source for c in candidates) or "none"
|
||||
raise PhotonDashboardAuthError(
|
||||
f"{dashboard_error} Device login returned no project-valid "
|
||||
f"dashboard token (tried: {sources})."
|
||||
) from dashboard_error
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError("Photon did not return a usable dashboard token")
|
||||
|
||||
|
||||
def login_device_flow(
|
||||
*,
|
||||
client_id: str = DEFAULT_CLIENT_ID,
|
||||
@@ -297,7 +450,13 @@ def login_device_flow(
|
||||
webbrowser.open(target, new=2)
|
||||
except Exception:
|
||||
pass
|
||||
token = poll_for_token(code, client_id=client_id)
|
||||
# Poll once for the approved token, then collect every candidate shape so
|
||||
# we can validate against the dashboard API before persisting (avoids
|
||||
# saving a token that authenticates the session lookup but 404s on the
|
||||
# project APIs).
|
||||
first_token = poll_for_token(code, client_id=client_id)
|
||||
candidates = [_DeviceTokenCandidate(source="poll", token=first_token)]
|
||||
token = _validated_dashboard_token(candidates)
|
||||
store_photon_token(token)
|
||||
return token
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"raysun12142006@gmail.com": "yanxue06",
|
||||
"alberto.regalado@ymail.com": "ARegalado1",
|
||||
"alchemistchaos@protonmail.com": "AlchemistChaos", # co-author only
|
||||
"gilad@smiti.ai": "giladbau",
|
||||
@@ -1241,6 +1242,7 @@ AUTHOR_MAP = {
|
||||
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
|
||||
# Debug share upload-time redaction (May 2026)
|
||||
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
|
||||
"github@nadyahermes.anonaddy.com": "ruangraung", # PR #42308
|
||||
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
|
||||
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
|
||||
"briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
"""Regression tests for issue #30768 and #32383.
|
||||
"""Regression tests for #30768, #32383, and #33961.
|
||||
|
||||
``_prompt_text_input_modal`` uses a queue-based modal that relies on
|
||||
prompt_toolkit key bindings receiving keyboard events. On Windows the
|
||||
prompt_toolkit input channel can deadlock when the modal is entered from
|
||||
the ``process_loop`` daemon thread. The fix falls back to the simpler
|
||||
``_prompt_text_input`` (stdin-based) prompt on Windows.
|
||||
``_prompt_text_input_modal`` answers destructive-slash confirmations through a
|
||||
queue-based modal driven by prompt_toolkit key bindings. When invoked from the
|
||||
``process_loop`` daemon thread it sets the modal up on the app's event loop via
|
||||
``call_soon_threadsafe``, so it is safe on every platform — including native
|
||||
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
|
||||
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.
|
||||
|
||||
These tests verify:
|
||||
1. Windows detection triggers the stdin fallback
|
||||
2. Non-Windows daemon threads still use the modal via the app loop
|
||||
3. macOS/Linux main-thread path still uses the modal (no regression)
|
||||
4. No-app path still uses the stdin fallback (existing behavior)
|
||||
5. Empty choices returns None (existing behavior)
|
||||
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
|
||||
Windows (#33961) — never the raw stdin fallback, never a hang.
|
||||
2. Main-thread confirm with a running app uses the modal.
|
||||
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
|
||||
(on win32, off-thread) a scheduling failure degrades to a clean cancel.
|
||||
4. Empty choices returns None.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing prompt/modal helpers."""
|
||||
"""Minimal HermesCLI shell exposing the prompt/modal helpers."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
@@ -37,9 +39,6 @@ def _make_cli():
|
||||
return obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample choices used across tests
|
||||
# ---------------------------------------------------------------------------
|
||||
_SAMPLE_CHOICES = [
|
||||
("once", "Approve Once", "proceed this time only"),
|
||||
("always", "Always Approve", "proceed and silence this prompt permanently"),
|
||||
@@ -47,119 +46,106 @@ _SAMPLE_CHOICES = [
|
||||
]
|
||||
|
||||
|
||||
class TestModalWindowsFallback:
|
||||
"""Windows dead-lock regression tests for _prompt_text_input_modal."""
|
||||
def _answer_modal_when_open(cli, response, stop=None):
|
||||
"""Push ``response`` onto the modal's response_queue once it opens.
|
||||
|
||||
def test_windows_falls_back_to_stdin(self):
|
||||
"""On Windows, _prompt_text_input_modal should use _prompt_text_input."""
|
||||
Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
|
||||
e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
|
||||
"""
|
||||
for _ in range(100):
|
||||
if stop is not None and stop.is_set():
|
||||
return
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put(response)
|
||||
return
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def _run_on_daemon(call, cli, *, platform, response, schedule=None):
|
||||
"""Invoke ``call`` on a daemon thread — as the process_loop does — answering
|
||||
the modal with ``response`` once it opens.
|
||||
|
||||
Returns ``{result, stdin_called, capture, restore}``. ``schedule`` overrides
|
||||
the ``call_soon_threadsafe`` side effect (default: run the callback inline);
|
||||
pass a raiser to simulate a scheduling failure. Fails if the worker hangs,
|
||||
which is the deadlock canary for #33961.
|
||||
"""
|
||||
outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
|
||||
done = threading.Event()
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", platform), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin, \
|
||||
patch.object(cli, "_invalidate"), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
|
||||
outcome["result"] = call()
|
||||
outcome["stdin_called"] = mock_stdin.called
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
|
||||
answerer.start()
|
||||
worker.start()
|
||||
worker.join(timeout=2.0)
|
||||
answerer.join(timeout=2.0)
|
||||
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
return outcome
|
||||
|
||||
|
||||
class TestModal:
|
||||
"""Behaviour of _prompt_text_input_modal across platforms and threads."""
|
||||
|
||||
@pytest.mark.parametrize("platform", ["linux", "win32"])
|
||||
def test_daemon_thread_uses_modal_via_app_loop(self, platform):
|
||||
"""Off the process_loop daemon thread, the confirm uses the modal via
|
||||
call_soon_threadsafe on every platform — including native Windows, where
|
||||
the old win32 early-return deadlocked on raw input() (#33961)."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new — destroys conversation state",
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
)
|
||||
timeout=5,
|
||||
),
|
||||
cli,
|
||||
platform=platform,
|
||||
response="once",
|
||||
)
|
||||
assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
|
||||
assert outcome["result"] == "once"
|
||||
assert outcome["capture"] == [1]
|
||||
assert outcome["restore"] == [1]
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
# The stdin-based fallback was used, not the modal queue path.
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "1"
|
||||
|
||||
def test_non_main_thread_uses_modal_via_app_loop(self):
|
||||
"""Off the main thread on Linux, keep the modal path via app-loop setup."""
|
||||
def test_main_thread_with_app_uses_modal(self):
|
||||
"""On the main thread with a running app, the queue-based modal is used."""
|
||||
cli = _make_cli()
|
||||
result_holder = {}
|
||||
setup_calls = []
|
||||
teardown_calls = []
|
||||
|
||||
def _call_soon_threadsafe(callback):
|
||||
callback()
|
||||
|
||||
def run_on_daemon():
|
||||
with patch.object(sys, "platform", "linux"), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin, \
|
||||
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")):
|
||||
result_holder["result"] = cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
result_holder["stdin_called"] = mock_stdin.called
|
||||
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("once")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
submitter.start()
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
submitter.join(timeout=2.0)
|
||||
assert not t.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
assert result_holder["stdin_called"] is False
|
||||
assert result_holder["result"] == "once"
|
||||
assert setup_calls == ["capture"]
|
||||
assert teardown_calls == ["restore"]
|
||||
|
||||
def test_main_thread_non_windows_uses_modal(self):
|
||||
"""On macOS/Linux main thread, the queue-based modal is still used."""
|
||||
cli = _make_cli()
|
||||
|
||||
# We need to simulate the modal receiving a response. We'll patch
|
||||
# the response_queue to immediately return a value.
|
||||
with patch.object(sys, "platform", "darwin"), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot"), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot"), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
# Start the modal in a way that it will receive a response
|
||||
# immediately via the queue.
|
||||
original_queue = queue.Queue
|
||||
original_time = time.monotonic
|
||||
patch.object(cli, "_invalidate"), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
|
||||
answerer.start()
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
answerer.join(timeout=2.0)
|
||||
|
||||
def _fake_modal_flow(*args, **kwargs):
|
||||
"""Simulate the modal flow: set state, put response, return."""
|
||||
# We'll directly test that the modal path is entered by
|
||||
# checking that _slash_confirm_state was set.
|
||||
pass
|
||||
|
||||
# Since we can't easily mock the internal queue, let's test
|
||||
# that the modal path is entered by checking that
|
||||
# _prompt_text_input was NOT called.
|
||||
with patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
# Set up a response that will be put into the queue
|
||||
# after the modal starts waiting.
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("once")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
submitter.start()
|
||||
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
submitter.join(timeout=2.0)
|
||||
|
||||
# The stdin fallback should NOT have been called.
|
||||
mock_stdin.assert_not_called()
|
||||
# The result should be "once" from the simulated modal response.
|
||||
assert result == "once"
|
||||
mock_stdin.assert_not_called()
|
||||
assert result == "once"
|
||||
|
||||
def test_no_app_falls_back_to_stdin(self):
|
||||
"""Without a prompt_toolkit app, always use stdin fallback."""
|
||||
"""Without a running app (oneshot / non-interactive), use the stdin prompt."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
@@ -173,78 +159,102 @@ class TestModalWindowsFallback:
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "3"
|
||||
|
||||
def test_empty_choices_returns_none(self):
|
||||
"""Empty choices list should return None without prompting."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="Test",
|
||||
detail="Test",
|
||||
choices=[],
|
||||
)
|
||||
|
||||
mock_stdin.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_windows_fallback_does_not_set_modal_state(self):
|
||||
"""Verify Windows fallback doesn't leave _slash_confirm_state set."""
|
||||
def test_windows_no_app_falls_back_to_stdin(self):
|
||||
"""win32 without a running app keeps stdin — the only case where the raw
|
||||
prompt is safe on Windows, since no app owns the console to deadlock."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1"):
|
||||
cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new — destroys conversation state",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
)
|
||||
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "1"
|
||||
|
||||
def test_windows_scheduling_failure_clean_cancels(self):
|
||||
"""win32 off the main thread: if marshaling onto the app loop fails, cancel
|
||||
cleanly (None) rather than fall to raw input() (which deadlocks on native
|
||||
Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
|
||||
cli = _make_cli()
|
||||
|
||||
def _raise(_cb):
|
||||
raise RuntimeError("loop closed")
|
||||
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
),
|
||||
cli,
|
||||
platform="win32",
|
||||
response="once",
|
||||
schedule=_raise,
|
||||
)
|
||||
assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
|
||||
assert outcome["result"] is None
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
def test_non_main_thread_modal_clears_state(self):
|
||||
"""Verify daemon-thread modal teardown does not leave state behind."""
|
||||
@pytest.mark.parametrize(
|
||||
"platform, expect_stdin, expect_result",
|
||||
[("win32", False, None), ("linux", True, "1")],
|
||||
)
|
||||
def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
|
||||
"""Off the daemon thread with no resolvable app loop (``self._app.loop``
|
||||
is None / raises), the modal can never be scheduled, so the method short-
|
||||
circuits at the app_loop-is-None site (cli.py ~7260) — a distinct path
|
||||
from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
|
||||
deadlocking on raw input(); other platforms keep the stdin prompt."""
|
||||
cli = _make_cli()
|
||||
errors = []
|
||||
cli._app.loop = None # forces app_loop is None, off the main thread
|
||||
|
||||
def _call_soon_threadsafe(callback):
|
||||
callback()
|
||||
outcome = {"result": None, "stdin_called": False}
|
||||
done = threading.Event()
|
||||
|
||||
def run_on_daemon():
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", "linux"), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe):
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("cancel")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
submitter.start()
|
||||
cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
with patch.object(sys, "platform", platform), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
submitter.join(timeout=2.0)
|
||||
if cli._slash_confirm_state is not None:
|
||||
errors.append("_slash_confirm_state should be None")
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
outcome["stdin_called"] = mock_stdin.called
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert not errors, f"unexpected errors: {errors}"
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=2.0)
|
||||
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
assert outcome["stdin_called"] is expect_stdin
|
||||
assert outcome["result"] == expect_result
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
def test_empty_choices_returns_none(self):
|
||||
"""Empty choices returns None without prompting."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])
|
||||
|
||||
mock_stdin.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConfirmDestructiveSlashWindows:
|
||||
"""Integration-level tests for _confirm_destructive_slash on Windows."""
|
||||
"""End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""
|
||||
|
||||
def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
|
||||
"""_confirm_destructive_slash should work on Windows via stdin fallback."""
|
||||
def _make_interactive_cli(self):
|
||||
cli = _make_cli()
|
||||
cli.model = "test-model"
|
||||
cli._agent_running = False
|
||||
@@ -255,37 +265,140 @@ class TestConfirmDestructiveSlashWindows:
|
||||
cli._pending_tool_info = {}
|
||||
cli._tool_start_time = 0.0
|
||||
cli._last_scrollback_tool = ""
|
||||
return cli
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1"), \
|
||||
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
result = cli._confirm_destructive_slash(
|
||||
"new",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
@pytest.mark.parametrize(
|
||||
"response, expected",
|
||||
[("once", "once"), ("cancel", None)],
|
||||
)
|
||||
def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
|
||||
"""On native Windows, the bare /new confirm drives the modal (not stdin)
|
||||
and returns the chosen outcome — the bug #33961 froze this path."""
|
||||
cli = self._make_interactive_cli()
|
||||
with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._confirm_destructive_slash(
|
||||
"new",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
),
|
||||
cli,
|
||||
platform="win32",
|
||||
response=response,
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
assert outcome["stdin_called"] is False
|
||||
assert outcome["result"] == expected
|
||||
|
||||
def test_confirm_destructive_slash_cancelled_on_windows(self):
|
||||
"""Cancellation via stdin fallback works on Windows."""
|
||||
|
||||
class TestNativeWindowsNoRawInputDeadlock:
|
||||
"""Anti-regression guard exercising the REAL ``_prompt_text_input``.
|
||||
|
||||
Every other test here mocks ``_prompt_text_input`` away, so they only
|
||||
assert *routing* (modal vs. stdin) — they cannot observe the actual hang
|
||||
that #33961 was. The historical regression was precisely that
|
||||
``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
|
||||
on native Windows, which on a non-main thread runs a bare ``input()`` that
|
||||
blocks forever against prompt_toolkit's stdin ownership.
|
||||
|
||||
These tests let the real ``_prompt_text_input`` run with a blocking
|
||||
``input()`` and assert the worker thread never hangs. They fail on the
|
||||
pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
|
||||
and pass once the modal path / clean-cancel fallback is in place.
|
||||
"""
|
||||
|
||||
def test_win32_daemon_thread_never_blocks_on_real_input(self):
|
||||
"""A blocking input() must NOT hang the daemon thread on win32.
|
||||
|
||||
Drives the genuine helper chain (no mock of ``_prompt_text_input``)
|
||||
with ``builtins.input`` patched to block forever. The confirm must
|
||||
resolve via the app-loop modal (answered on a background thread, as
|
||||
the real key bindings would) and never sit in ``input()``. On the
|
||||
pre-#33961 code the win32 early-return routed to the real
|
||||
``_prompt_text_input`` → off-main ``input()`` → permanent hang.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli.model = "test-model"
|
||||
cli._agent_running = False
|
||||
cli._spinner_text = ""
|
||||
cli._should_exit = False
|
||||
cli._command_running = False
|
||||
cli.session_id = "test-session"
|
||||
cli._pending_tool_info = {}
|
||||
cli._tool_start_time = 0.0
|
||||
cli._last_scrollback_tool = ""
|
||||
cli._app.loop.call_soon_threadsafe = lambda cb: cb()
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="3"), \
|
||||
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
result = cli._confirm_destructive_slash(
|
||||
"reset",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
)
|
||||
def _blocking_input(prompt=""): # stands in for "no line ever arrives"
|
||||
time.sleep(30)
|
||||
return "1"
|
||||
|
||||
# Choice "3" normalizes to "cancel", which returns None.
|
||||
assert result is None
|
||||
outcome = {}
|
||||
done = threading.Event()
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch("builtins.input", side_effect=_blocking_input), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot"), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot"), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="/new",
|
||||
detail="destroys conversation state",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=3,
|
||||
)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
answerer = threading.Thread(
|
||||
target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
|
||||
)
|
||||
answerer.start()
|
||||
worker.start()
|
||||
worker.join(timeout=5.0)
|
||||
answerer.join(timeout=5.0)
|
||||
assert not worker.is_alive(), (
|
||||
"daemon thread hung in real input() — native-Windows confirm "
|
||||
"deadlock regressed (#33961)"
|
||||
)
|
||||
# cancel → None; the point is it RETURNED rather than blocking forever.
|
||||
assert outcome.get("result") in (None, "cancel")
|
||||
|
||||
def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
|
||||
"""If the modal can't be marshaled onto the app loop on native Windows
|
||||
(scheduling failure) the off-main-thread path must cancel cleanly —
|
||||
NOT fall through to a blocking raw ``input()``.
|
||||
|
||||
This is the degraded branch the pre-#33961 code handled with
|
||||
``return self._prompt_text_input(...)`` (which deadlocks); the fix
|
||||
returns ``None`` instead.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
|
||||
def _raise(cb): # call_soon_threadsafe scheduling failure
|
||||
raise RuntimeError("event loop closed")
|
||||
|
||||
cli._app.loop.call_soon_threadsafe = _raise
|
||||
|
||||
input_called = {"n": 0}
|
||||
|
||||
def _tracking_input(prompt=""):
|
||||
input_called["n"] += 1
|
||||
time.sleep(30)
|
||||
return "1"
|
||||
|
||||
outcome = {}
|
||||
|
||||
def _worker():
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch("builtins.input", side_effect=_tracking_input), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="/new",
|
||||
detail="destroys conversation state",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=5.0)
|
||||
assert not worker.is_alive(), (
|
||||
"daemon thread hung — win32 scheduling-failure fallback used raw "
|
||||
"input() instead of cleanly cancelling (#33961)"
|
||||
)
|
||||
assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
|
||||
assert outcome.get("result") is None
|
||||
|
||||
@@ -1264,3 +1264,123 @@ async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, t
|
||||
assert VerboseAgent.LONG_CODE not in all_content
|
||||
# But should still contain the truncated portion with "..."
|
||||
assert "..." in all_content
|
||||
|
||||
|
||||
class CodeBlockProgressAdapter(ProgressCaptureAdapter):
|
||||
"""A markdown-capable progress adapter (declares supports_code_blocks)."""
|
||||
|
||||
supports_code_blocks = True
|
||||
|
||||
|
||||
class TerminalCommandAgent:
|
||||
"""Emits a terminal tool.started with a real, multi-line command arg."""
|
||||
|
||||
CMD = (
|
||||
"set -euo pipefail\n"
|
||||
"printf 'node: '; node --version\n"
|
||||
"npm install -g hyperframes@latest"
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
self.tool_progress_callback(
|
||||
"tool.started", "terminal", self.CMD, {"command": self.CMD}
|
||||
)
|
||||
# Let the async progress task drain the queue and send before returning.
|
||||
time.sleep(0.35)
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch, tmp_path):
|
||||
"""Regression for #41215: terminal progress must render as a short truncated
|
||||
preview, never the full command in a fenced ```bash block, even on a
|
||||
markdown-capable (supports_code_blocks) gateway."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = TerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-no-bash-block",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
# Compact truncated preview, not a fenced bash block.
|
||||
assert "```bash" not in all_content
|
||||
assert 'terminal: "' in all_content
|
||||
# The full multi-line command body must not reach the chat.
|
||||
assert "npm install -g hyperframes@latest" not in all_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):
|
||||
"""#41215 also rendered the bash block in verbose mode. The revert removed it
|
||||
from both branches, so verbose progress must not emit a fenced ```bash block
|
||||
either (verbose still shows args by opt-in, just not as a code block)."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = TerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-verbose-no-bash",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert "```bash" not in all_content
|
||||
|
||||
@@ -835,7 +835,7 @@ class TestEditMessageStreamingSafety:
|
||||
assert second_call == {
|
||||
"chat_id": 123,
|
||||
"message_id": 456,
|
||||
"text": "final **bold**",
|
||||
"text": "final bold",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -540,6 +540,54 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
||||
)
|
||||
|
||||
|
||||
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" provider: custom:hpc-ai\n"
|
||||
" default: deepseek/deepseek-v4-flash\n"
|
||||
"custom_providers:\n"
|
||||
" - name: hpc-ai\n"
|
||||
" base_url: https://hpc-ai.example/v1\n"
|
||||
" api_key: test-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
(tmp_path / "project").mkdir(exist_ok=True)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
out = buf.getvalue()
|
||||
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
|
||||
assert "model.provider 'custom:hpc-ai' is unknown" not in out
|
||||
assert (
|
||||
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
|
||||
"'custom:hpc-ai'"
|
||||
not in out
|
||||
)
|
||||
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
|
||||
|
||||
|
||||
|
||||
|
||||
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
|
||||
|
||||
@@ -65,6 +65,15 @@ def test_resolve_provider_full_finds_named_custom_provider():
|
||||
assert resolved.source == "user-config"
|
||||
|
||||
|
||||
def test_is_aggregator_recognizes_named_custom_provider():
|
||||
assert providers_mod.is_aggregator("custom:hpc-ai") is True
|
||||
assert providers_mod.is_aggregator("custom:litellm") is True
|
||||
|
||||
|
||||
def test_is_aggregator_leaves_unknown_provider_non_aggregator():
|
||||
assert providers_mod.is_aggregator("not-a-provider") is False
|
||||
|
||||
|
||||
def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):
|
||||
"""Shared /model switch pipeline should accept --provider for custom_providers."""
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -97,7 +97,11 @@ def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert code.expires_in == 600
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
assert captured["body"]["client_id"] == "hermes-agent"
|
||||
# Hosted Photon allowlists registered device clients — an unregistered
|
||||
# client_id is rejected with 400 invalid_client. We use Photon's published
|
||||
# CLI device client and send the standard scope.
|
||||
assert captured["body"]["client_id"] == "photon-cli"
|
||||
assert captured["body"]["scope"] == "openid profile email"
|
||||
|
||||
|
||||
def test_poll_for_token_via_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -281,3 +285,108 @@ def test_print_credential_summary_emits_only_display_strings(
|
||||
assert "proj-uuid" in blob # project id is intentionally surfaced
|
||||
# Header is always emitted
|
||||
assert any("Photon iMessage status" in line for line in lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-token candidate extraction + dashboard validation.
|
||||
|
||||
def test_device_response_candidates_covers_known_shapes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{
|
||||
"access_token": "tok-snake",
|
||||
"accessToken": "tok-camel",
|
||||
"data": {"access_token": "tok-data"},
|
||||
},
|
||||
headers={"set-auth-token": "Bearer tok-header"},
|
||||
)
|
||||
by_source = {c.source: c.token for c in candidates}
|
||||
assert by_source["access_token"] == "tok-snake"
|
||||
assert by_source["accessToken"] == "tok-camel"
|
||||
assert by_source["data.access_token"] == "tok-data"
|
||||
# "Bearer " prefix is stripped from the header value.
|
||||
assert by_source["set-auth-token"] == "tok-header"
|
||||
|
||||
|
||||
def test_device_response_candidates_dedupes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{"access_token": "same", "accessToken": "same"},
|
||||
)
|
||||
assert [c.token for c in candidates] == ["same"]
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_unrecognized_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={}) # no "user" key
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_project_api_denial(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(status=403) # project API rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_login_device_flow_validates_before_persisting(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
# device/token approval
|
||||
return _FakeResponse(json_body={"access_token": "good-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(json_body=[]) # projects OK
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
token = photon_auth.login_device_flow(open_browser=False)
|
||||
assert token == "good-token"
|
||||
assert photon_auth.load_photon_token() == "good-token"
|
||||
|
||||
|
||||
def test_login_device_flow_raises_when_token_invalid(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
return _FakeResponse(json_body={"access_token": "bad-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(status=401) # session lookup rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.login_device_flow(open_browser=False)
|
||||
# A token that failed validation must never be persisted.
|
||||
assert photon_auth.load_photon_token() is None
|
||||
|
||||
Reference in New Issue
Block a user