- {groups.map(group => (
-
- {group.provider.name}
- {group.families.map(family => {
- // The active id may be the base or its -fast sibling; either
- // way this one family row represents both.
- const activeId =
- group.provider.slug === optionsProvider &&
- (optionsModel === family.id || optionsModel === family.fastId)
- ? optionsModel
- : null
+ {groups.map(group => {
+ const slug = group.provider.slug
- const isCurrent = activeId !== null
- const name = modelDisplayParts(family.id).name
- // Capabilities are looked up against the active/base id; the
- // -fast variant carries the same param support as its base.
- const caps = group.provider.capabilities?.[family.id]
+ // Collapsed when stored + no active search + not the current provider.
+ const collapsed = collapsedProviders.includes(slug) && !search && slug !== optionsProvider
- // Effective settings for this row: live session state when it's
- // the active model, otherwise its remembered preset (Hermes
- // defaults when unset). Row label AND submenu read from these so
- // they never disagree.
- const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
- const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '')
- const effFast = isCurrent ? currentFastMode : (preset.fast ?? false)
+ return (
+
+ {
+ event.preventDefault()
+ toggleCollapsedProvider(slug)
+ }}
+ textValue=""
+ >
+ {collapsed ? (
+
+ ) : (
+
+ )}
+ {group.provider.name}
+
+ {!collapsed &&
+ group.families.map(family => {
+ // The active id may be the base or its -fast sibling; either
+ // way this one family row represents both.
+ const activeId =
+ group.provider.slug === optionsProvider &&
+ (optionsModel === family.id || optionsModel === family.fastId)
+ ? optionsModel
+ : null
- const fastControl = resolveFastControl(
- activeId ?? family.id,
- group.provider.models ?? [],
- caps?.fast ?? false,
- effFast
- )
+ const isCurrent = activeId !== null
+ const name = modelDisplayParts(family.id).name
+ // Capabilities are looked up against the active/base id; the
+ // -fast variant carries the same param support as its base.
+ const caps = group.provider.capabilities?.[family.id]
- const meta = [
- fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
- (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
- ]
- .filter(Boolean)
- .join(' ')
+ // Effective settings for this row: live session state when it's
+ // the active model, otherwise its remembered preset (Hermes
+ // defaults when unset). Row label AND submenu read from these so
+ // they never disagree.
+ const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
+ const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '')
+ const effFast = isCurrent ? currentFastMode : (preset.fast ?? false)
- // Every row is a hover-Edit submenu trigger. Activating it
- // (pointer or keyboard) switches to the family's base model and
- // restores its preset; the Fast toggle inside swaps to the -fast
- // sibling (or flips the speed param). The sub-trigger has no
- // `onSelect`, so wire both click and Enter/Space for keyboard parity.
- // Clicking the row commits the model and closes the picker; the
- // edit submenu (reasoning/fast) is reached by HOVER, so you can
- // still tweak those without the click dismissing everything.
- const activate = () => {
- if (!isCurrent) {
- void selectFamily(family, group.provider)
- }
+ const fastControl = resolveFastControl(
+ activeId ?? family.id,
+ group.provider.models ?? [],
+ caps?.fast ?? false,
+ effFast
+ )
- closeMenu()
- }
+ const meta = [
+ fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
+ (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
+ ]
+ .filter(Boolean)
+ .join(' ')
- return (
-
- {
- if (event.key === 'Enter' || event.key === ' ') {
- activate()
- }
- }}
- >
-
- {name}
- {meta ? {meta} : null}
-
- {isCurrent ? : null}
-
- switchTo(nextModel, group.provider.slug)}
- provider={group.provider.slug}
- reasoning={caps?.reasoning ?? true}
- requestGateway={requestGateway}
- />
-
- )
- })}
-
- ))}
+ // Every row is a hover-Edit submenu trigger. Activating it
+ // (pointer or keyboard) switches to the family's base model and
+ // restores its preset; the Fast toggle inside swaps to the -fast
+ // sibling (or flips the speed param). The sub-trigger has no
+ // `onSelect`, so wire both click and Enter/Space for keyboard parity.
+ // Clicking the row commits the model and closes the picker; the
+ // edit submenu (reasoning/fast) is reached by HOVER, so you can
+ // still tweak those without the click dismissing everything.
+ const activate = () => {
+ if (!isCurrent) {
+ void selectFamily(family, group.provider)
+ }
+
+ closeMenu()
+ }
+
+ return (
+
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ activate()
+ }
+ }}
+ >
+
+ {name}
+ {meta ? {meta} : null}
+
+ {isCurrent ? (
+
+ ) : null}
+
+ switchTo(nextModel, group.provider.slug)}
+ provider={group.provider.slug}
+ reasoning={caps?.reasoning ?? true}
+ requestGateway={requestGateway}
+ />
+
+ )
+ })}
+
+ )
+ })}
)}
diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts
index 6eac1a3dd754..3f8da4144334 100644
--- a/apps/desktop/src/app/types.ts
+++ b/apps/desktop/src/app/types.ts
@@ -154,6 +154,8 @@ export interface ClientSessionState {
sawAssistantPayload: boolean
pendingBranchGroup: string | null
interrupted: boolean
+ /** True after message.interim finalized a bubble in the still-running turn. */
+ interimBoundaryPending: boolean
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
diff --git a/apps/desktop/src/components/assistant-ui/ansi-text.tsx b/apps/desktop/src/components/assistant-ui/ansi-text.tsx
index 99ced1f6c44e..100e0f739c8f 100644
--- a/apps/desktop/src/components/assistant-ui/ansi-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/ansi-text.tsx
@@ -1,5 +1,4 @@
-import type { FC } from 'react'
-import { useMemo } from 'react'
+import { memo, useMemo } from 'react'
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
import { cn } from '@/lib/utils'
@@ -12,7 +11,7 @@ interface AnsiTextProps {
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
* back to a plain string node when no codes are present so the parser cost
* is paid only when there's something to colorize. */
-export const AnsiText: FC = ({ className, text }) => {
+export const AnsiText = memo(({ className, text }: AnsiTextProps) => {
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
if (!segments) {
@@ -31,4 +30,4 @@ export const AnsiText: FC = ({ className, text }) => {
))}
)
-}
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
index b4920e1ec483..f6edaee0d7e3 100644
--- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx
@@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
- expect(screen.getByText('5s')).toBeTruthy()
+ expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
sessionA.unmount()
$activeSessionId.set('session-b')
@@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => {
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
- expect(screen.getByText('3s')).toBeTruthy()
+ expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
- expect(screen.getByText('8s')).toBeTruthy()
+ expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
})
})
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts
index ea253ecadcba..c3f5361191d1 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts
@@ -8,6 +8,7 @@ import {
countDiffLineStats,
inlineDiffFromResult,
MAX_TOOL_RENDER_CHARS,
+ prettyJson,
type ToolPart
} from './fallback-model'
@@ -342,15 +343,16 @@ describe('clampForDisplay', () => {
})
// A large tool result (e.g. a 100KB read_file during a `/learn` run) must not
-// be serialized into the rendered rawResult at full size — that JSON.stringify
-// payload is what floods the renderer when many rows stack up.
-describe('buildToolView caps serialized result size', () => {
- it('clamps rawResult for an oversized result', () => {
+// be serialized at full size — that JSON.stringify payload is what floods the
+// renderer. buildToolView no longer prettyJson's every result eagerly; the
+// web_search drilldown serializes lazily via prettyJson, which clamps.
+describe('prettyJson caps serialized result size', () => {
+ it('clamps an oversized result', () => {
const huge = 'y'.repeat(MAX_TOOL_RENDER_CHARS * 3)
- const view = buildToolView(part({ result: { content: huge }, toolName: 'read_file' }), '')
+ const out = prettyJson({ content: huge })
- expect(view.rawResult.length).toBeLessThanOrEqual(MAX_TOOL_RENDER_CHARS + 200)
- expect(view.rawResult).toContain('truncated')
+ expect(out.length).toBeLessThanOrEqual(MAX_TOOL_RENDER_CHARS + 200)
+ expect(out).toContain('truncated')
})
})
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
index e027e97ef685..a1e8720431cf 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
@@ -11,7 +11,6 @@ import {
isRecord,
numberValue,
parseMaybeObject,
- prettyJson,
unwrapToolPayload
} from './format'
import { findFirstUrl, hostnameOf, looksLikePath, looksLikeUrl } from './targets'
@@ -1409,8 +1408,6 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
imageUrl: toolImageUrl(argsRecord, resultRecord),
inlineDiff,
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
- rawArgs: prettyJson(part.args),
- rawResult: prettyJson(part.result),
rendersAnsi: rendersAnsi || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts
index b4225310f8c3..e61140a12a6e 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/types.ts
@@ -36,8 +36,6 @@ export interface ToolView {
imageUrl?: string
inlineDiff: string
previewTarget?: string
- rawArgs: string
- rawResult: string
/** Set for tools whose output naturally contains ANSI escape codes
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
index 96db51f49550..bf4cfaed6d0e 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx
@@ -62,6 +62,7 @@ import {
type ToolStatus,
type ToolTitleAction
} from './fallback-model'
+import { prettyJson } from './fallback-model/format'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@@ -311,17 +312,22 @@ function ToolEntry({ part }: ToolEntryProps) {
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
- const activeSessionId = useStore($activeSessionId)
- const currentCwd = useStore($currentCwd)
const previewTarget = view.previewTarget
useEffect(() => {
- if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
+ if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}
- recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
- }, [activeSessionId, currentCwd, isPending, previewTarget])
+ // Read (don't subscribe) session/cwd: this only fires when a previewable
+ // target appears, and subscribing re-rendered every tool row on any session
+ // or cwd change.
+ const activeSessionId = $activeSessionId.get()
+
+ if (activeSessionId) {
+ recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '')
+ }
+ }, [isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {
@@ -348,15 +354,17 @@ function ToolEntry({ part }: ToolEntryProps) {
return { body: rest.join('\n\n').trim(), summary }
}, [view.detail, view.status, view.subtitle])
- const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail)
+ // `looksRedundant` normalizes the FULL (uncapped) detail payload — a
+ // read_file / terminal result can be huge. Memoize on the view fields so it
+ // recomputes only when the tool's content changes, not on every parent
+ // re-render (tool rows re-render on every stream tick of the running message).
+ const detailMatchesSubtitle = useMemo(() => looksRedundant(view.subtitle, view.detail), [view.subtitle, view.detail])
+ const detailMatchesTitle = useMemo(() => looksRedundant(view.title, view.detail), [view.title, view.detail])
const showDetail =
!view.inlineDiff &&
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
- (view.status !== 'error' &&
- Boolean(view.detail) &&
- !looksRedundant(view.title, view.detail) &&
- !detailMatchesSubtitle))
+ (view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
const renderDetailAsCode =
view.status !== 'error' &&
@@ -365,11 +373,18 @@ function ToolEntry({ part }: ToolEntryProps) {
const hasSearchHits = Boolean(view.searchHits?.length)
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
+ // Only web_search renders the raw JSON drilldown, so serialize the result
+ // lazily here instead of prettyJson-ing every tool's result in buildToolView.
+ const rawResult = useMemo(
+ () => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
+ [part.toolName, part.result, toolViewMode]
+ )
+
const showRawSearchDrilldown =
part.toolName === 'web_search' &&
part.result !== undefined &&
toolViewMode !== 'technical' &&
- Boolean(view.rawResult.trim())
+ Boolean(rawResult.trim())
const hasExpandableContent = Boolean(
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
@@ -584,9 +599,7 @@ function ToolEntry({ part }: ToolEntryProps) {
{showRawSearchDrilldown && (
{copy.rawResponse}
-
- {view.rawResult}
-
+
{rawResult}
)}
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
diff --git a/apps/desktop/src/components/chat/activity-timer-text.tsx b/apps/desktop/src/components/chat/activity-timer-text.tsx
index aa439eb247d5..d9ca1c292e29 100644
--- a/apps/desktop/src/components/chat/activity-timer-text.tsx
+++ b/apps/desktop/src/components/chat/activity-timer-text.tsx
@@ -1,6 +1,7 @@
import { cn } from '@/lib/utils'
import { formatElapsed } from './activity-timer'
+import { StableText } from './stable-text'
interface ActivityTimerTextProps {
seconds: number
@@ -9,16 +10,16 @@ interface ActivityTimerTextProps {
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
return (
-
{formatElapsed(seconds)}
-
+
)
}
diff --git a/apps/desktop/src/components/chat/compact-markdown.tsx b/apps/desktop/src/components/chat/compact-markdown.tsx
index 79e96e8fa657..ed3389464a49 100644
--- a/apps/desktop/src/components/chat/compact-markdown.tsx
+++ b/apps/desktop/src/components/chat/compact-markdown.tsx
@@ -1,4 +1,5 @@
import type { ComponentProps, ElementType, FC } from 'react'
+import { memo } from 'react'
import { Streamdown } from 'streamdown'
import { ExternalLink, ExternalLinkIcon } from '@/lib/external-link'
@@ -102,7 +103,13 @@ const COMPONENTS = {
ul: tagged('ul')
}
-export function CompactMarkdown({ className, text }: { className?: string; text: string }) {
+export const CompactMarkdown = memo(function CompactMarkdown({
+ className,
+ text
+}: {
+ className?: string
+ text: string
+}) {
return (
@@ -110,4 +117,4 @@ export function CompactMarkdown({ className, text }: { className?: string; text:
)
-}
+})
diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx
index eef495c7c54c..edcf08e38f70 100644
--- a/apps/desktop/src/components/chat/diff-lines.tsx
+++ b/apps/desktop/src/components/chat/diff-lines.tsx
@@ -559,9 +559,20 @@ interface FileDiffPanelProps {
/** Render an old/new line-number gutter (the full preview diff). The compact
* tool-card + inline review diff leave this off. */
showLineNumbers?: boolean
+ /** Window the rows (fixed-row virtualization) WITHOUT a gutter — for a large
+ * diff in a scrolling pane (the review panel), so only visible rows mount
+ * instead of highlighting every line. `showLineNumbers` implies windowing. */
+ virtualized?: boolean
}
-export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers = false }: FileDiffPanelProps) {
+export function FileDiffPanel({
+ className,
+ diff,
+ fullText,
+ path,
+ showLineNumbers = false,
+ virtualized = false
+}: FileDiffPanelProps) {
const lines = React.useMemo(
() => (fullText != null ? parseFullFileDiff(diff, fullText) : parseDiff(diff)),
[diff, fullText]
@@ -580,66 +591,79 @@ export function FileDiffPanel({ className, diff, fullText, path, showLineNumbers
const language = shikiLanguageForFilename(path)
const canHighlight = Boolean(language) && !exceedsHighlightBudget(fullText ?? diff)
+ const windowed = showLineNumbers || virtualized
- // Full-file preview: we own the rows (tokens rendered inside) so blank lines
- // can't collapse. Compact tool/review diffs let Shiki own the rows.
- const body = !canHighlight ? (
- showLineNumbers ? (
-
- ) : (
-
- )
- ) : fullText != null ? (
+ // Windowed: we own fixed-height rows and render only the visible chunks, so a
+ // large diff never mounts (or Shiki-highlights) every line. Compact tool cards
+ // are small/clamped, so they let Shiki own the rows (SyntaxDiff).
+ const windowedBody = canHighlight ? (
+ ) : (
+
+ )
+
+ const compactBody = !canHighlight ? (
+
+ ) : fullText != null ? (
+
) : (
)
- if (!showLineNumbers) {
+ if (!windowed) {
return (
- {body}
+ {compactBody}
)
}
- // A single line-number gutter (VS Code's inline-diff style): each row shows its
- // own file's number — the new number for context/adds, the old number for
- // removals — with an overview ruler pinned to the right edge. The inner div
- // owns the scroll so the ruler (an absolute sibling) stays viewport-fixed.
+ // Windowed: a fixed-row scroller renders only the visible rows (killing the
+ // full-Shiki-of-every-line freeze on large diffs). With `showLineNumbers` a
+ // VS Code-style gutter (new number for context/adds, old for removals) sits in
+ // a left column; the scroller owns scroll so the overview ruler (an absolute
+ // sibling) stays viewport-fixed.
return (
diff --git a/apps/desktop/src/components/chat/stable-text.tsx b/apps/desktop/src/components/chat/stable-text.tsx
new file mode 100644
index 000000000000..1e7e03815cd6
--- /dev/null
+++ b/apps/desktop/src/components/chat/stable-text.tsx
@@ -0,0 +1,23 @@
+import { cn } from '@/lib/utils'
+
+interface StableTextProps {
+ children: string
+ className?: string
+}
+
+/**
+ * Renders text as a row of 1ch-wide cells so individual characters can't
+ * shift the layout as they change (e.g. digits in a ticking timer).
+ * Works with any proportional font — no need for font-mono.
+ */
+export function StableText({ children, className }: StableTextProps) {
+ return (
+
+ {children.split('').map((char, i) => (
+
+ {char}
+
+ ))}
+
+ )
+}
diff --git a/apps/desktop/src/components/idle-mount.test.tsx b/apps/desktop/src/components/idle-mount.test.tsx
new file mode 100644
index 000000000000..db51c1578c83
--- /dev/null
+++ b/apps/desktop/src/components/idle-mount.test.tsx
@@ -0,0 +1,50 @@
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { IdleMount } from './idle-mount'
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('IdleMount', () => {
+ it('mounts eagerly where requestIdleCallback is unavailable', () => {
+ vi.stubGlobal('requestIdleCallback', undefined)
+
+ render(
+
+ child
+
+ )
+
+ expect(screen.getByText('child')).toBeTruthy()
+ })
+
+ it('defers the child to idle, then keeps it mounted', () => {
+ let fire: (() => void) | null = null
+
+ const ric = vi.fn((cb: () => void) => {
+ fire = cb
+
+ return 1
+ })
+
+ vi.stubGlobal('requestIdleCallback', ric)
+ vi.stubGlobal('cancelIdleCallback', vi.fn())
+
+ render(
+
+ child
+
+ )
+
+ // Not on the first-paint path — nothing rendered until the browser is idle.
+ expect(screen.queryByText('child')).toBeNull()
+ expect(ric).toHaveBeenCalledOnce()
+
+ act(() => fire?.())
+
+ expect(screen.getByText('child')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/idle-mount.tsx b/apps/desktop/src/components/idle-mount.tsx
new file mode 100644
index 000000000000..9a15ce84b508
--- /dev/null
+++ b/apps/desktop/src/components/idle-mount.tsx
@@ -0,0 +1,28 @@
+import { type ReactNode, useEffect, useState } from 'react'
+
+/**
+ * Mounts `children` only once the browser goes idle (or `timeout` ms elapse),
+ * then keeps them mounted for good. Lifts non-critical, boot-hidden surfaces
+ * (display:none panes: files/preview/review/logs) off the first-paint critical
+ * path with ZERO visible change — idle fires within a frame of first paint, so
+ * a hidden pane is warm long before the user can reveal it, preserving the
+ * "toggle back is instant" contract while shrinking cold-start app-mount.
+ *
+ * Degrades to eager mount where requestIdleCallback is absent (jsdom/tests,
+ * older webviews), so there's no behavioral fork to reason about there.
+ */
+export function IdleMount({ children, timeout = 2000 }: { children: ReactNode; timeout?: number }) {
+ const [ready, setReady] = useState(typeof requestIdleCallback !== 'function')
+
+ useEffect(() => {
+ if (ready) {
+ return undefined
+ }
+
+ const id = requestIdleCallback(() => setReady(true), { timeout })
+
+ return () => cancelIdleCallback(id)
+ }, [ready, timeout])
+
+ return ready ? <>{children}> : null
+}
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts
new file mode 100644
index 000000000000..8cfe0560ebcd
--- /dev/null
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+
+import { forceLoneHeaderForPanes } from './lone-header'
+
+describe('forceLoneHeaderForPanes', () => {
+ const chrome =
+ (placement?: string, uncloseable = false) =>
+ () => ({ placement, uncloseable })
+
+ const noCollapse = () => false
+
+ it('forces a header for session-tile ids even without registered chrome', () => {
+ expect(forceLoneHeaderForPanes(['session-tile:abc'], () => ({}), noCollapse)).toBe(true)
+ })
+
+ it('forces a header for closeable placement:main panes', () => {
+ expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
+ expect(forceLoneHeaderForPanes(['some-page'], chrome('main', false), noCollapse)).toBe(true)
+ })
+
+ it('forces a header for a lone collapse tool pane', () => {
+ expect(
+ forceLoneHeaderForPanes(
+ ['terminal'],
+ () => ({}),
+ id => id === 'terminal'
+ )
+ ).toBe(true)
+ })
+
+ it('leaves a lone uncloseable workspace headerless', () => {
+ expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts
new file mode 100644
index 000000000000..1a0ab4c07359
--- /dev/null
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts
@@ -0,0 +1,36 @@
+/**
+ * When a lone pane must keep its tab strip (name card + close).
+ *
+ * Default: a single pane isn't a "tab", so the header auto-hides. Exceptions
+ * force it on so a closeable surface never becomes an unclosable dead zone:
+ * - session tiles (`session-tile:*`) — even before chrome registers
+ * - any closeable `placement: 'main'` contribution
+ * - a collapse tool panel dragged into its own zone
+ */
+
+export interface LoneHeaderChrome {
+ placement?: string
+ uncloseable?: boolean
+}
+
+export function forceLoneHeaderForPanes(
+ shown: readonly string[],
+ chromeOf: (id: string) => LoneHeaderChrome,
+ isCollapsePane: (id: string) => boolean
+): boolean {
+ if (shown.some(id => id.startsWith('session-tile:'))) {
+ return true
+ }
+
+ if (
+ shown.some(id => {
+ const chrome = chromeOf(id)
+
+ return !chrome.uncloseable && chrome.placement === 'main'
+ })
+ ) {
+ return true
+ }
+
+ return shown.length === 1 && isCollapsePane(shown[0])
+}
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
index ff15a790bc83..8ba225a4bad8 100644
--- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
@@ -52,6 +52,7 @@ import {
} from '../store'
import { type DoubleTapContext, startPaneDrag } from './drag-session'
+import { forceLoneHeaderForPanes } from './lone-header'
import { paneChrome } from './track-model'
/** A directional action in the zone menu (computed per group state). */
@@ -187,13 +188,9 @@ export function TreeGroup({
// The uncloseable workspace and side chrome (sessions/files) keep the clean
// no-tab default. Double-click toggles it either way; a minimized group
// always shows its header (it IS the header).
- const forceLoneHeader =
- shown.some(id => {
- const chrome = paneChrome(paneFor(id))
-
- return !chrome.uncloseable && chrome.placement === 'main'
- }) ||
- (shown.length === 1 && isCollapsePane(shown[0]))
+ // Session-tile ids force the header even before chrome registers — cycling
+ // onto a freshly-split tile used to land headerless ("name card missing").
+ const forceLoneHeader = forceLoneHeaderForPanes(shown, id => paneChrome(paneFor(id)), isCollapsePane)
// A full-page view (headerVeto) suppresses the strip while it's the active
// pane — a page is not a tab-able surface; the bar returns with the chat.
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
index 41225a478073..d963d79efb8c 100644
--- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
@@ -10,6 +10,7 @@ import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import { useContributions } from '@/contrib/react/use-contributions'
+import { rafCoalesce } from '@/lib/raf-coalesce'
import { cn } from '@/lib/utils'
import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
@@ -234,9 +235,9 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
- const onMove = (ev: PointerEvent) => {
- const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
-
+ // pointermove outpaces 60fps and each write relayouts the whole pane tree,
+ // so coalesce to one apply per frame (rafCoalesce commits on cleanup).
+ const applyShift = (shiftPx: number) => {
if (a.fixed) {
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
}
@@ -247,15 +248,21 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
if (!a.fixed && !b.fixed) {
const weights = [...node.weights]
- // Convert the CLAMPED pixel sizes back to weights so the persisted
- // weights always agree with what's on screen.
+ // Clamped px → weights so persisted weights match what's on screen.
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
setTreeSplitWeights(node.id, weights)
}
}
+ const resize = rafCoalesce(applyShift)
+
+ const onMove = (ev: PointerEvent) => {
+ resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)))
+ }
+
const cleanup = () => {
+ resize.finish()
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect
diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts
index 4ead79c098f7..5013e9f2ddd8 100644
--- a/apps/desktop/src/components/pane-shell/tree/store.ts
+++ b/apps/desktop/src/components/pane-shell/tree/store.ts
@@ -375,7 +375,15 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
}
const idx = Math.max(0, panes.indexOf(group!.active ?? ''))
- activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length])
+ const nextId = panes[(idx + direction + panes.length) % panes.length]
+ activateTreePane(group!.id, nextId)
+
+ // Cycling onto a session/main tab must surface the name card — a zone that
+ // was double-tap-hidden stays headerless otherwise ("the one that cycles
+ // never gets it").
+ if (nextId === 'workspace' || nextId.startsWith('session-tile:')) {
+ setTreeGroupHeaderHidden(group!.id, false)
+ }
return true
}
diff --git a/apps/desktop/src/hermes-cron-scope.test.ts b/apps/desktop/src/hermes-cron-scope.test.ts
new file mode 100644
index 000000000000..ac94bc6698fd
--- /dev/null
+++ b/apps/desktop/src/hermes-cron-scope.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ createCronJob,
+ deleteCronJob,
+ getCronJob,
+ getCronJobRuns,
+ getCronJobs,
+ pauseCronJob,
+ resumeCronJob,
+ setApiRequestProfile,
+ triggerCronJob,
+ updateCronJob
+} from './hermes'
+
+// Contract: every cron helper must carry the active gateway profile, so a
+// multi-profile / remote user's cron list, runs, and mutations hit the backend
+// they're actually on — not the primary/default. Without it, selecting a remote
+// profile still showed the local primary's jobs (the "remote cron jobs don't
+// show up" bug), the counterpart to the backend-action-helper fix in
+// hermes-profile-scope.test.ts.
+describe('cron helpers are profile-scoped', () => {
+ const api = vi.fn(async (_req: { path: string; profile?: string }) => ({}) as never)
+
+ beforeEach(() => {
+ ;(window as { hermesDesktop?: unknown }).hermesDesktop = { api }
+ api.mockClear()
+ })
+
+ afterEach(() => {
+ setApiRequestProfile(null)
+ delete (window as { hermesDesktop?: unknown }).hermesDesktop
+ })
+
+ const lastProfile = () => api.mock.calls.at(-1)?.[0].profile
+
+ it('omits profile when none is active (single-profile users unaffected)', () => {
+ void getCronJobs()
+ expect(lastProfile()).toBeUndefined()
+ })
+
+ it('forwards the active profile to every cron helper', () => {
+ setApiRequestProfile('coder')
+
+ void getCronJobs()
+ void getCronJob('job-1')
+ void getCronJobRuns('job-1')
+ void createCronJob({ name: 'nightly', prompt: 'run', schedule: '0 3 * * *' } as never)
+ void updateCronJob('job-1', { enabled: false } as never)
+ void pauseCronJob('job-1')
+ void resumeCronJob('job-1')
+ void triggerCronJob('job-1')
+ void deleteCronJob('job-1')
+
+ for (const call of api.mock.calls) {
+ expect(call[0].profile).toBe('coder')
+ }
+ })
+
+ it('list accepts an explicit ?profile= for endpoint-level filtering', () => {
+ // profileScoped() routes the backend process; the list endpoint ALSO
+ // aggregates 'all' by default, so callers pass an explicit profile to
+ // filter what the endpoint returns (sidebar / cron overlay scoping).
+ void getCronJobs('worker_alpha')
+ expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=worker_alpha')
+
+ void getCronJobs('all')
+ expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=all')
+
+ // Omitting the arg keeps the legacy unfiltered path.
+ void getCronJobs()
+ expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs')
+ })
+})
diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts
index 3de900c18b2f..f829471c5a33 100644
--- a/apps/desktop/src/hermes.test.ts
+++ b/apps/desktop/src/hermes.test.ts
@@ -1,6 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS,
+ AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
+ AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS,
+ AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
+ audioSpeakRequestTimeoutMs,
+ audioTranscribeRequestTimeoutMs,
getCronJobs,
getGlobalModelInfo,
getGlobalModelOptions,
@@ -11,7 +17,10 @@ import {
getStatus,
listAllProfileSessions,
listSessions,
- listSidebarSessions
+ listSidebarSessions,
+ resetSidebarBatchCapability,
+ speakText,
+ transcribeAudio
} from './hermes'
import { refreshActiveProfile } from './store/profile'
@@ -22,10 +31,11 @@ const emptySessionsResponse = {
total: 0
}
-describe('Hermes REST session helpers', () => {
+describe('Hermes REST helpers', () => {
let api: ReturnType
beforeEach(() => {
+ resetSidebarBatchCapability()
api = vi.fn().mockResolvedValue(emptySessionsResponse)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
@@ -99,6 +109,151 @@ describe('Hermes REST session helpers', () => {
expect(result.messaging.sessions).toEqual([])
})
+ it('falls back to the per-slice endpoint when the batched route 404s on an older backend', async () => {
+ const row = (id: string) => ({ id, title: id, profile: 'default' })
+
+ api.mockImplementation(({ path }: { path: string }) => {
+ if (path.startsWith('/api/profiles/sessions/sidebar')) {
+ // The exact skew failure: Electron surfaces the backend catch-all.
+ return Promise.reject(
+ new Error(
+ 'Error invoking remote method \'hermes:api\': Error: 404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'
+ )
+ )
+ }
+
+ if (path.includes('source=cron')) {
+ return Promise.resolve({ ...emptySessionsResponse, sessions: [row('cron-1')], total: 1 })
+ }
+
+ if (path.includes('exclude_sources=cron%2Cdesktop')) {
+ return Promise.resolve({ ...emptySessionsResponse, sessions: [row('msg-1')], total: 1 })
+ }
+
+ return Promise.resolve({
+ ...emptySessionsResponse,
+ sessions: [row('recent-1')],
+ total: 7,
+ profile_totals: { default: 7 }
+ })
+ })
+
+ const result = await listSidebarSessions({
+ recentsProfile: 'work',
+ recentsLimit: 30,
+ recentsExclude: ['cron', 'tool'],
+ cronLimit: 50,
+ messagingLimit: 100,
+ messagingExclude: ['cron', 'desktop']
+ })
+
+ // Slices reassembled from the legacy per-slice route with the same
+ // scoping: recents on the caller's profile, cron + messaging cross-profile.
+ expect(result.recents.sessions.map(s => s.id)).toEqual(['recent-1'])
+ expect(result.recents.total).toBe(7)
+ expect(result.recents.profile_totals).toEqual({ default: 7 })
+ expect(result.cron.sessions.map(s => s.id)).toEqual(['cron-1'])
+ expect(result.messaging.sessions.map(s => s.id)).toEqual(['msg-1'])
+
+ const paths = api.mock.calls.map(call => (call[0] as { path: string }).path)
+ expect(paths.filter(p => p.startsWith('/api/profiles/sessions/sidebar'))).toHaveLength(1)
+ expect(paths.filter(p => p.startsWith('/api/profiles/sessions?'))).toHaveLength(3)
+ expect(paths).toContainEqual(expect.stringContaining('profile=work'))
+ expect(paths).toContainEqual(expect.stringContaining('source=cron'))
+ expect(paths).toContainEqual(expect.stringContaining('exclude_sources=cron%2Ctool'))
+ })
+
+ it('remembers endpoint-missing and skips re-probing the batched route on later refreshes', async () => {
+ api.mockImplementation(({ path }: { path: string }) =>
+ path.startsWith('/api/profiles/sessions/sidebar')
+ ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
+ : Promise.resolve(emptySessionsResponse)
+ )
+
+ const req = {
+ recentsProfile: 'all' as const,
+ recentsLimit: 20,
+ recentsExclude: [],
+ cronLimit: 50,
+ messagingLimit: 100,
+ messagingExclude: []
+ }
+
+ await listSidebarSessions(req)
+ await listSidebarSessions(req)
+
+ const batchedProbes = api.mock.calls.filter(call =>
+ (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
+ )
+
+ // First refresh probes once and learns; the second goes straight to the
+ // per-slice route (3 calls each refresh, no repeated dead probe).
+ expect(batchedProbes).toHaveLength(1)
+ expect(api.mock.calls.length).toBe(1 + 3 + 3)
+ })
+
+ it('re-probes the batched route after a gateway switch resets the capability flag', async () => {
+ api.mockImplementation(({ path }: { path: string }) =>
+ path.startsWith('/api/profiles/sessions/sidebar')
+ ? Promise.reject(new Error('404: {"detail":"No such API endpoint: /api/profiles/sessions/sidebar"}'))
+ : Promise.resolve(emptySessionsResponse)
+ )
+
+ const req = {
+ recentsProfile: 'all' as const,
+ recentsLimit: 20,
+ recentsExclude: [],
+ cronLimit: 50,
+ messagingLimit: 100,
+ messagingExclude: []
+ }
+
+ await listSidebarSessions(req)
+ // Soft gateway switch: the next backend may support the batched route.
+ resetSidebarBatchCapability()
+ api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
+
+ const result = await listSidebarSessions(req)
+
+ const batchedProbes = api.mock.calls.filter(call =>
+ (call[0] as { path: string }).path.startsWith('/api/profiles/sessions/sidebar')
+ )
+
+ expect(batchedProbes).toHaveLength(2)
+ expect(result.recents.sessions).toEqual([])
+ })
+
+ it('does NOT fall back on transient failures — only endpoint-missing shapes trigger legacy mode', async () => {
+ api.mockRejectedValue(new Error('Request timed out after 60000ms'))
+
+ await expect(
+ listSidebarSessions({
+ recentsProfile: 'all',
+ recentsLimit: 20,
+ recentsExclude: [],
+ cronLimit: 50,
+ messagingLimit: 100,
+ messagingExclude: []
+ })
+ ).rejects.toThrow('timed out')
+
+ // One call: the batched probe. No legacy fan-out, no sticky degradation.
+ expect(api).toHaveBeenCalledTimes(1)
+
+ // And the next refresh still uses the batched route.
+ api.mockResolvedValue({ recents: { sessions: [] }, cron: { sessions: [] }, messaging: { sessions: [] } })
+ await listSidebarSessions({
+ recentsProfile: 'all',
+ recentsLimit: 20,
+ recentsExclude: [],
+ cronLimit: 50,
+ messagingLimit: 100,
+ messagingExclude: []
+ })
+
+ expect((api.mock.calls[1][0] as { path: string }).path).toMatch(/^\/api\/profiles\/sessions\/sidebar\?/)
+ })
+
it('uses a longer timeout for profile listing during desktop startup', async () => {
api.mockResolvedValue({ profiles: [] })
@@ -175,6 +330,62 @@ describe('Hermes REST session helpers', () => {
})
})
+ it('bounds blocking TTS synthesis timeouts by text length', () => {
+ expect(audioSpeakRequestTimeoutMs('short message')).toBe(AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS)
+ expect(audioSpeakRequestTimeoutMs('x'.repeat(8_000))).toBe(280_000)
+ expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS)
+ })
+
+ it('uses an extended timeout for blocking TTS synthesis', async () => {
+ api.mockResolvedValueOnce({
+ data_url: 'data:audio/mpeg;base64,AA==',
+ mime_type: 'audio/mpeg',
+ ok: true,
+ provider: 'openai'
+ })
+
+ await expect(speakText('Read this aloud')).resolves.toEqual({
+ data_url: 'data:audio/mpeg;base64,AA==',
+ mime_type: 'audio/mpeg',
+ ok: true,
+ provider: 'openai'
+ })
+
+ expect(api).toHaveBeenCalledWith({
+ body: { text: 'Read this aloud' },
+ method: 'POST',
+ path: '/api/audio/speak',
+ timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS
+ })
+ })
+
+ it('bounds blocking transcription timeouts by payload length', () => {
+ expect(audioTranscribeRequestTimeoutMs('data:audio/webm;base64,AA==')).toBe(AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS)
+ expect(audioTranscribeRequestTimeoutMs('x'.repeat(3_000_000))).toBe(300_000)
+ expect(audioTranscribeRequestTimeoutMs('x'.repeat(9_000_000))).toBe(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS)
+ })
+
+ it('uses an extended timeout for blocking transcription', async () => {
+ api.mockResolvedValueOnce({
+ ok: true,
+ provider: 'openai',
+ text: 'transcribed text'
+ })
+
+ await expect(transcribeAudio('data:audio/webm;base64,AA==', 'audio/webm')).resolves.toEqual({
+ ok: true,
+ provider: 'openai',
+ text: 'transcribed text'
+ })
+
+ expect(api).toHaveBeenCalledWith({
+ body: { data_url: 'data:audio/webm;base64,AA==', mime_type: 'audio/webm' },
+ method: 'POST',
+ path: '/api/audio/transcribe',
+ timeoutMs: AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS
+ })
+ })
+
it('defaults model options to configured providers only', async () => {
await getGlobalModelOptions()
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index f86843cd3b54..a3d4199fa789 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -14,6 +14,9 @@ import type {
CronJobCreatePayload,
CronJobUpdates,
CuratorStatusResponse,
+ CustomEndpointsResponse,
+ CustomEndpointUpdate,
+ CustomEndpointValidationResponse,
DebugShareResponse,
ElevenLabsVoicesResponse,
EnvVarInfo,
@@ -81,6 +84,36 @@ const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only
// ever fires when the turn itself would have been abandoned server-side.
export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000
+export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000
+export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000
+const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35
+
+export function audioSpeakRequestTimeoutMs(text: string): number {
+ const estimated = Math.max(
+ AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
+ Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)
+ )
+
+ return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)
+}
+
+export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000
+export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000
+// The transcribe payload is the base64 audio data URL itself, so its string
+// length tracks clip size. ~0.1ms/char keeps short clips at the floor while
+// letting multi-minute recordings scale toward the cap (a base64 char is
+// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of
+// timeout per 1s of audio before the cap clamps it).
+const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1
+
+export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {
+ const estimated = Math.max(
+ AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
+ Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)
+ )
+
+ return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)
+}
export type {
ActionResponse,
@@ -105,6 +138,10 @@ export type {
CronJobSchedule,
CronJobUpdates,
CuratorStatusResponse,
+ CustomEndpoint,
+ CustomEndpointsResponse,
+ CustomEndpointUpdate,
+ CustomEndpointValidationResponse,
DebugShareResponse,
ElevenLabsVoice,
ElevenLabsVoicesResponse,
@@ -387,7 +424,72 @@ export interface SidebarSessionsRequest {
messagingExclude: string[]
}
+// The batched /sidebar endpoint shipped later than the per-slice route, so a
+// newer desktop can meet an older backend that 404s it ("No such API
+// endpoint"). Endpoint-missing is a capability signal, not a transient
+// failure: remember it (per renderer lifetime — a runtime home change reloads
+// the window and re-probes) and serve every subsequent refresh straight from
+// the three proven per-slice calls instead of re-probing a known-dead route
+// once per turn/broadcast.
+let sidebarBatchEndpointMissing = false
+
+// Capability flags are per-backend facts. A hard re-home reloads the window
+// (module state resets naturally), but a soft gateway switch re-dials in
+// place — the next backend may well have the batched route, so the switch
+// paths call this to re-probe rather than leak the old backend's capability.
+export function resetSidebarBatchCapability() {
+ sidebarBatchEndpointMissing = false
+}
+
+// True only for "the route does not exist on this backend" shapes: the
+// backend catch-all ('404: {"detail":"No such API endpoint: ...}'), FastAPI's
+// bare 404 on headless serve (surfaces as '404: ...' directly or as
+// "Error invoking remote method 'hermes:api': Error: 404: ..." through the
+// IPC bridge), and the Electron JSON-guard ("endpoint is likely missing").
+// This GET has no path params, so a 404 status can only mean route-missing —
+// but transient failures (timeouts, 5xx, connection refused) must NOT match,
+// or one blip would silently degrade the fast path for the whole session.
+function isEndpointMissingError(err: unknown): boolean {
+ const message = err instanceof Error ? err.message : String(err)
+
+ return (
+ /no such api endpoint/i.test(message) ||
+ /endpoint is likely missing/i.test(message) ||
+ /(?:^\s*|error:\s*)404\b/i.test(message)
+ )
+}
+
+// Compatibility fallback: reassemble the three sidebar slices from the
+// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
+// archived excluded, recency order; recents scoped to the caller's profile,
+// cron + messaging cross-profile). Rides the same Electron remote-splice
+// interception as the pre-batching desktop, so remote profiles stay correct.
+async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise {
+ const [recents, cron, messaging] = await Promise.all([
+ listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
+ excludeSources: req.recentsExclude
+ }),
+ listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', 'all', { source: 'cron' }),
+ listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', 'all', {
+ excludeSources: req.messagingExclude
+ })
+ ])
+
+ const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
+
+ return {
+ recents: { profile_totals: recents.profile_totals, sessions: recents.sessions, total: recents.total },
+ cron: { sessions: cron.sessions },
+ messaging: { sessions: messaging.sessions },
+ ...(errors.length ? { errors } : {})
+ }
+}
+
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise {
+ if (sidebarBatchEndpointMissing) {
+ return listSidebarSessionsLegacy(req)
+ }
+
const params = new URLSearchParams({
recents_profile: req.recentsProfile,
recents_limit: String(Math.max(1, req.recentsLimit)),
@@ -403,10 +505,23 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<
params.set('messaging_exclude', req.messagingExclude.join(','))
}
- const result = await window.hermesDesktop.api({
- path: `/api/profiles/sessions/sidebar?${params.toString()}`,
- timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
- })
+ let result: SidebarSessionsResponse
+
+ try {
+ result = await window.hermesDesktop.api({
+ path: `/api/profiles/sessions/sidebar?${params.toString()}`,
+ timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
+ })
+ } catch (err) {
+ if (!isEndpointMissingError(err)) {
+ throw err
+ }
+
+ // Older backend without the batched route (desktop/runtime version skew).
+ sidebarBatchEndpointMissing = true
+
+ return listSidebarSessionsLegacy(req)
+ }
return {
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
@@ -619,6 +734,42 @@ export function validateProviderCredential(
})
}
+export function getCustomEndpoints(): Promise {
+ return window.hermesDesktop.api({
+ path: '/api/providers/custom-endpoints'
+ })
+}
+
+export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise {
+ return window.hermesDesktop.api({
+ path: '/api/providers/custom-endpoints',
+ method: 'POST',
+ body: endpoint
+ })
+}
+
+export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise {
+ return window.hermesDesktop.api({
+ path: '/api/providers/custom-endpoints/validate',
+ method: 'POST',
+ body: endpoint
+ })
+}
+
+export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
+ return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
+ path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
+ method: 'POST'
+ })
+}
+
+export function deleteCustomEndpoint(id: string): Promise {
+ return window.hermesDesktop.api({
+ path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
+ method: 'DELETE'
+ })
+}
+
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
@@ -956,21 +1107,31 @@ export function testMessagingPlatform(platformId: string): Promise {
+// Cron jobs are stored per-profile (/cron/jobs.json), and the
+// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
+// list just that profile's jobs, or 'all' for the unified cross-profile view.
+// Omitting the arg keeps the legacy 'all' default for non-profile callers.
+// profileScoped() still rides along for backend-process routing.
+export function getCronJobs(profile?: string): Promise {
+ const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
+
return window.hermesDesktop.api({
- path: '/api/cron/jobs',
+ ...profileScoped(),
+ path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getCronJob(jobId: string): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}
export async function getCronJobRuns(jobId: string, limit = 20): Promise {
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})
@@ -979,6 +1140,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
@@ -987,6 +1149,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise {
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
@@ -995,6 +1158,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
@@ -1002,6 +1166,7 @@ export function pauseCronJob(jobId: string): Promise {
export function resumeCronJob(jobId: string): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
@@ -1009,6 +1174,7 @@ export function resumeCronJob(jobId: string): Promise {
export function triggerCronJob(jobId: string): Promise {
return window.hermesDesktop.api({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
method: 'POST'
})
@@ -1016,6 +1182,7 @@ export function triggerCronJob(jobId: string): Promise {
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
+ ...profileScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'DELETE'
})
@@ -1209,7 +1376,11 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise {
return window.hermesDesktop.api({
path: '/api/audio/speak',
method: 'POST',
- body: { text }
+ body: { text },
+ // TTS blocks until provider synthesis, file read, and base64 encoding
+ // finish. Remote providers and large messages regularly exceed the
+ // default 15s Electron backend timeout.
+ timeoutMs: audioSpeakRequestTimeoutMs(text)
})
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 7765c0f7468f..a0e79037f875 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -314,6 +314,7 @@ export const en: Translations = {
providers: 'Providers',
providerAccounts: 'Accounts',
providerApiKeys: 'API keys',
+ providerCustomEndpoints: 'Custom Endpoints',
gateway: 'Gateway',
apiKeys: 'Tools & Keys',
keybinds: 'Keyboard Shortcuts',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index fcc27776201e..ed902c8ee8b7 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -215,6 +215,7 @@ export const ja = defineLocale({
providers: 'プロバイダー',
providerAccounts: 'アカウント',
providerApiKeys: 'API キー',
+ providerCustomEndpoints: 'カスタムエンドポイント',
gateway: 'ゲートウェイ',
apiKeys: 'ツールとキー',
keybinds: 'キーボードショートカット',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 40f224f2b242..ae2ec69bcdac 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -272,6 +272,7 @@ export interface Translations {
providers: string
providerAccounts: string
providerApiKeys: string
+ providerCustomEndpoints: string
gateway: string
apiKeys: string
keybinds: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 4c543feb986a..c30818309a42 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -209,6 +209,7 @@ export const zhHant = defineLocale({
providers: '提供方',
providerAccounts: '帳號',
providerApiKeys: 'API 金鑰',
+ providerCustomEndpoints: '自訂端點',
gateway: '閘道',
apiKeys: '工具與金鑰',
keybinds: '鍵盤快捷鍵',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 07c01b7395e6..253e01326c61 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -305,6 +305,7 @@ export const zh: Translations = {
providers: '提供方',
providerAccounts: '账号',
providerApiKeys: 'API 密钥',
+ providerCustomEndpoints: '自定义端点',
gateway: '网关',
apiKeys: '工具与密钥',
keybinds: '键盘快捷键',
diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts
index 44a6915fed7e..e63fae09cb89 100644
--- a/apps/desktop/src/lib/chat-messages.test.ts
+++ b/apps/desktop/src/lib/chat-messages.test.ts
@@ -5,7 +5,9 @@ import {
appendAssistantTextPart,
appendReasoningPart,
chatMessageText,
+ mergeFinalAssistantText,
preserveLocalAssistantErrors,
+ reasoningPart,
renderMediaTags,
toChatMessages,
upsertToolPart
@@ -785,3 +787,65 @@ describe('upsertToolPart', () => {
})
})
})
+
+describe('mergeFinalAssistantText', () => {
+ it('removes all text parts and appends the final text', () => {
+ const parts = [
+ { type: 'text' as const, text: 'streamed delta 1' },
+ { type: 'text' as const, text: 'streamed delta 2' },
+ { type: 'tool-call' as const, toolCallId: 'tc1', toolName: 'terminal', args: {} as never, argsText: '{}' }
+ ]
+
+ const result = mergeFinalAssistantText(parts, 'final answer')
+
+ expect(result.filter(p => p.type === 'text')).toHaveLength(1)
+ expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'final answer' })
+ expect(result.some(p => p.type === 'tool-call')).toBe(true)
+ })
+
+ it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => {
+ const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }]
+
+ const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.')
+
+ expect(result.filter(p => p.type === 'reasoning')).toHaveLength(0)
+ expect(result.filter(p => p.type === 'text')).toHaveLength(1)
+ })
+
+ it('keeps a longer reasoning block when the final text is only a short prefix', () => {
+ // #61447: a short final ("Done.") must NOT swallow a longer reasoning block
+ // that merely starts with it.
+ const parts = [
+ reasoningPart(
+ 'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'
+ ),
+ { type: 'text' as const, text: 'streamed' }
+ ]
+
+ const result = mergeFinalAssistantText(parts, 'Done.')
+
+ expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)
+ expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'Done.' })
+ })
+
+ it('keeps non-restating reasoning', () => {
+ const parts = [
+ reasoningPart('I analyzed the issue and found a race condition in the event loop.'),
+ { type: 'text' as const, text: 'streamed' }
+ ]
+
+ const result = mergeFinalAssistantText(parts, 'Fixed the race condition.')
+
+ expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)
+ expect(result.filter(p => p.type === 'text')).toHaveLength(1)
+ })
+
+ it('handles empty final text', () => {
+ const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')]
+
+ const result = mergeFinalAssistantText(parts, '')
+
+ expect(result.filter(p => p.type === 'text')).toHaveLength(0)
+ expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)
+ })
+})
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index 9488bd4cb4f0..4bc50d32569a 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -87,6 +87,9 @@ export type GatewayEventPayload = {
label?: string
index?: number
aggregator?: string
+ // message.complete — signals the final text was already previewed via
+ // interim_assistant_callback, so the UI can settle instead of duplicating.
+ response_previewed?: boolean
}
export function textPart(text: string): ChatMessagePart {
@@ -136,6 +139,46 @@ export function chatMessageText(message: ChatMessage): string {
.join('')
}
+const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
+
+/**
+ * Merge the final assistant text into a message's parts.
+ *
+ * - Removes all existing `text` parts (they were streamed deltas, now superseded
+ * by the authoritative final response).
+ * - Keeps `reasoning` parts, but drops one that the final text fully covers
+ * (reasoning ⊆ final) — the final restates it. A short final ("Done.") must
+ * NOT swallow a longer reasoning block that merely starts with it (#61447).
+ * - Keeps all other part types (tool-call, image, etc.).
+ * - Appends the final text as a new text part.
+ */
+export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] {
+ const dedupeReference = normalizeWs(finalText)
+
+ const kept = parts.filter(part => {
+ if (part.type === 'text') {
+ // Sealed text parts were already finalized into their own bubbles —
+ // this filter only runs on the LAST streaming bubble, so there are no
+ // sealed parts here. All text parts are streamed deltas that get
+ // replaced by the authoritative final text.
+ return false
+ }
+
+ if (part.type !== 'reasoning' || !dedupeReference) {
+ return true
+ }
+
+ // Reasoning is a restatement only when the final FULLY covers it.
+ // The reverse direction is not considered — a short final must not
+ // swallow a longer reasoning block (#61447).
+ const r = normalizeWs(part.text)
+
+ return !(r && dedupeReference.startsWith(r))
+ })
+
+ return finalText ? [...kept, assistantTextPart(finalText)] : kept
+}
+
const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\n)--- Attached Context ---\s*\n/
const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\n)--- Context Warnings ---[\s\S]*$/
const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)/g
diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts
index 0e200a50da8e..775961d9546b 100644
--- a/apps/desktop/src/lib/chat-runtime.ts
+++ b/apps/desktop/src/lib/chat-runtime.ts
@@ -54,6 +54,7 @@ export function createClientSessionState(
sawAssistantPayload: false,
pendingBranchGroup: null,
interrupted: false,
+ interimBoundaryPending: false,
needsInput: false,
turnStartedAt: null,
usage: null
diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts
index 7435d22d6ee4..02c3f643ca6a 100644
--- a/apps/desktop/src/lib/gateway-events.test.ts
+++ b/apps/desktop/src/lib/gateway-events.test.ts
@@ -13,6 +13,7 @@ describe('gateway event routing', () => {
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
+ expect(gatewayEventRequiresSessionId('message.interim')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts
index 67753c5fcd92..005e79705c15 100644
--- a/apps/desktop/src/lib/gateway-events.ts
+++ b/apps/desktop/src/lib/gateway-events.ts
@@ -24,6 +24,7 @@ const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'error',
'message.complete',
'message.delta',
+ 'message.interim',
'message.start',
'reasoning.available',
'reasoning.delta',
diff --git a/apps/desktop/src/lib/raf-coalesce.ts b/apps/desktop/src/lib/raf-coalesce.ts
new file mode 100644
index 000000000000..788b99d6e834
--- /dev/null
+++ b/apps/desktop/src/lib/raf-coalesce.ts
@@ -0,0 +1,34 @@
+/** Coalesce a stream of values (pointermove positions, resize deltas) to one
+ * `apply` per animation frame, so a drag can't drive several layouts per frame.
+ * `push` records the latest value and schedules a frame; `finish` commits the
+ * last value and cancels any pending frame (call it on pointerup/cancel).
+ * `null` is the empty sentinel, so `T` must never legitimately be `null`. */
+export function rafCoalesce(apply: (value: T) => void): { finish: () => void; push: (value: T) => void } {
+ let frame: null | number = null
+ let pending: null | T = null
+
+ const flush = () => {
+ frame = null
+
+ if (pending !== null) {
+ apply(pending)
+ }
+ }
+
+ return {
+ finish() {
+ if (frame !== null) {
+ cancelAnimationFrame(frame)
+ frame = null
+ }
+
+ if (pending !== null) {
+ apply(pending)
+ }
+ },
+ push(value) {
+ pending = value
+ frame ??= requestAnimationFrame(flush)
+ }
+ }
+}
diff --git a/apps/desktop/src/lib/stable-array.ts b/apps/desktop/src/lib/stable-array.ts
new file mode 100644
index 000000000000..b1e415ededfd
--- /dev/null
+++ b/apps/desktop/src/lib/stable-array.ts
@@ -0,0 +1,7 @@
+/** Keep `prev`'s reference when it's element-equal to `next`, so a nanostores
+ * `computed` (notifies on `!==`) skips the emit when its projected list didn't
+ * actually change — e.g. status-id sets recomputed on every stream delta.
+ * `next` is frozen: the ref is shared across ticks, so an in-place mutation
+ * would corrupt the cache — fail loud instead. */
+export const stableArray = (prev: readonly T[], next: T[]): readonly T[] =>
+ prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : Object.freeze(next)
diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.tsx
similarity index 92%
rename from apps/desktop/src/lib/statusbar.ts
rename to apps/desktop/src/lib/statusbar.tsx
index b06f9cc0d60e..0c0d22599c58 100644
--- a/apps/desktop/src/lib/statusbar.ts
+++ b/apps/desktop/src/lib/statusbar.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
+import { StableText } from '@/components/chat/stable-text'
import { compactNumber } from '@/lib/format'
import type { UsageStats } from '@/types/hermes'
@@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) {
return () => window.clearInterval(timer)
}, [since])
- return since ? formatDuration(now - since) : null
+ if (!since) {
+ return null
+ }
+
+ return {formatDuration(now - since)}
}
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index 343816b8e024..b1dd657655ba 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -17,7 +17,11 @@ import { ThemeProvider } from './themes/context'
installClipboardShim()
-if (import.meta.env.MODE !== 'production') {
+// The perf probe ships in dev, and in a production build ONLY when explicitly
+// opted in (VITE_PERF_PROBE=1) — this lets the perf harness measure a real,
+// minified production renderer for representative absolute numbers. Normal
+// `npm run build` leaves the flag unset, so the probe never reaches users.
+if (import.meta.env.MODE !== 'production' || import.meta.env.VITE_PERF_PROBE === '1') {
import('./app/chat/perf-probe')
}
diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts
index b87cf389a1bb..4ba910f47e34 100644
--- a/apps/desktop/src/store/composer-status.ts
+++ b/apps/desktop/src/store/composer-status.ts
@@ -1,6 +1,7 @@
import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
+import { stableArray } from '@/lib/stable-array'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { $gateway } from './gateway'
@@ -44,22 +45,24 @@ export const $backgroundStatusBySession = atom {
const ids = new Set()
for (const [runtimeId, items] of Object.entries(bg)) {
- if (!items.some(i => i.state === 'running')) {
- continue
- }
+ if (items.some(i => i.state === 'running')) {
+ const storedId = states[runtimeId]?.storedSessionId
- const storedId = states[runtimeId]?.storedSessionId
-
- if (storedId) {
- ids.add(storedId)
+ if (storedId) {
+ ids.add(storedId)
+ }
}
}
- return [...ids]
+ return (backgroundRunningIds = stableArray(backgroundRunningIds, [...ids]))
})
// Rows the user X-ed away. The registry keeps finished processes around for a
diff --git a/apps/desktop/src/store/gateway-switch.test.ts b/apps/desktop/src/store/gateway-switch.test.ts
index 5513f4f4b290..4ee362a4babf 100644
--- a/apps/desktop/src/store/gateway-switch.test.ts
+++ b/apps/desktop/src/store/gateway-switch.test.ts
@@ -15,6 +15,7 @@ import {
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
+import { $stalledSessionIds } from '@/store/session-states'
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch'
@@ -29,6 +30,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
setSessionsTotal(1)
setCronSessions([{ id: 'c1', title: 'cron', profile: 'default' } as never])
setMessagingSessions([{ id: 'm1', title: 'tg', profile: 'default' } as never])
+ $stalledSessionIds.set(['s1'])
setSessionsLoading(false)
setFreshDraftReady(false)
$sessionsLimit.set(SIDEBAR_SESSIONS_PAGE_SIZE * 3)
@@ -39,6 +41,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
+ $stalledSessionIds.set([])
setSessionsLoading(true)
$gatewaySwitching.set(false)
})
@@ -50,6 +53,7 @@ describe('wipeSessionListsForGatewaySwitch', () => {
expect($sessionsTotal.get()).toBe(0)
expect($cronSessions.get()).toEqual([])
expect($messagingSessions.get()).toEqual([])
+ expect($stalledSessionIds.get()).toEqual([])
expect($sessionsLoading.get()).toBe(true)
expect($sessionsLimit.get()).toBe(SIDEBAR_SESSIONS_PAGE_SIZE)
expect($freshDraftReady.get()).toBe(true)
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index 2b6fb466e8e7..291b803283a9 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -1,5 +1,6 @@
import { atom } from 'nanostores'
+import { resetSidebarBatchCapability } from '@/hermes'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import { resetSessionsLimit } from '@/store/layout'
import {
@@ -37,6 +38,9 @@ export const $gatewaySwitching = atom(false)
* alone so the user stays where they were (e.g. mid-Gateway settings).
*/
export function wipeSessionListsForGatewaySwitch(): void {
+ // The next backend is a different runtime — don't carry the old one's
+ // "batched sidebar endpoint missing" capability verdict across the switch.
+ resetSidebarBatchCapability()
setSessions([])
setSessionsTotal(0)
setSessionProfileTotals({})
@@ -45,8 +49,8 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessagingPlatformTotals({})
setMessagingTruncated(false)
// Clearing $sessionStates automatically clears $workingSessionIds and
- // $attentionSessionIds (they're computed from it). $unreadFinishedSessionIds
- // is separate (transient, not computable) so wipe it explicitly.
+ // $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
+ // $unreadFinishedSessionIds is separate, so wipe it explicitly.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
setSessionsLoading(true)
diff --git a/apps/desktop/src/store/model-presets.test.ts b/apps/desktop/src/store/model-presets.test.ts
index efe49ffa6e53..ef37cecc07d7 100644
--- a/apps/desktop/src/store/model-presets.test.ts
+++ b/apps/desktop/src/store/model-presets.test.ts
@@ -1,9 +1,14 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
+import { $currentFastMode, $currentReasoningEffort, setCurrentFastMode, setCurrentReasoningEffort } from './session'
describe('model presets', () => {
- beforeEach(() => $modelPresets.set({}))
+ beforeEach(() => {
+ $modelPresets.set({})
+ setCurrentFastMode(false)
+ setCurrentReasoningEffort('')
+ })
it('round-trips a preset and merges patches without dropping prior fields', () => {
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
@@ -35,7 +40,7 @@ describe('model presets', () => {
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
})
- it('no-ops without a session so selecting a model cannot mutate global config', async () => {
+ it('applies a fresh-draft preset locally without mutating gateway config', async () => {
const calls: { method: string; params?: Record }[] = []
const request = async (method: string, params?: Record) => {
@@ -46,6 +51,8 @@ describe('model presets', () => {
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
+ expect($currentReasoningEffort.get()).toBe('high')
+ expect($currentFastMode.get()).toBe(true)
expect(calls).toEqual([])
})
})
diff --git a/apps/desktop/src/store/model-presets.ts b/apps/desktop/src/store/model-presets.ts
index 9a66a8b0d2ce..ea9fc837eefd 100644
--- a/apps/desktop/src/store/model-presets.ts
+++ b/apps/desktop/src/store/model-presets.ts
@@ -4,6 +4,7 @@ import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
+import { sessionTileDelegate } from './session-states'
const STORAGE_KEY = 'hermes.desktop.model-presets'
@@ -51,27 +52,38 @@ export function setModelPreset(provider: string, model: string, patch: ModelPres
persistString(STORAGE_KEY, JSON.stringify(next))
}
-/** Push a model's preset onto the active session (optimistic + gateway).
+/** Apply a model's preset to the composer, then push it to a live session.
* `undefined` skips that dimension; values are capability-gated upstream.
- * No-ops without a session — the gateway's `config.set` reasoning/fast fall
- * back to persistent (global/profile) config when none matches, so selecting
- * a model must not reach it (else it rewrites `agent.*`, defaults included). */
+ * Without a session the local draft still needs the preset, but must not call
+ * `config.set`: that falls back to persistent profile config when no session
+ * matches and would rewrite the user's defaults.
+ *
+ * `primary: false` scopes the optimistic write to the tile's session slice —
+ * a tile's picker must not clobber the primary composer's effort/fast. */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
- ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
+ ctx: { failMessage: string; primary?: boolean; request: RequestGateway; sessionId: null | string }
): Promise {
+ if (ctx.primary ?? true) {
+ if (effort !== undefined) {
+ setCurrentReasoningEffort(effort)
+ }
+
+ if (fast !== undefined) {
+ setCurrentFastMode(fast)
+ }
+ } else if (ctx.sessionId) {
+ sessionTileDelegate()?.updateSession(ctx.sessionId, state => ({
+ ...state,
+ ...(effort !== undefined ? { reasoningEffort: effort } : {}),
+ ...(fast !== undefined ? { fast } : {})
+ }))
+ }
+
if (!ctx.sessionId) {
return
}
- if (effort !== undefined) {
- setCurrentReasoningEffort(effort)
- }
-
- if (fast !== undefined) {
- setCurrentFastMode(fast)
- }
-
try {
if (effort !== undefined) {
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
diff --git a/apps/desktop/src/store/provider-collapse.ts b/apps/desktop/src/store/provider-collapse.ts
new file mode 100644
index 000000000000..d7da2c90a88c
--- /dev/null
+++ b/apps/desktop/src/store/provider-collapse.ts
@@ -0,0 +1,28 @@
+import { Codecs, persistentAtom } from '@/lib/persisted'
+
+const STORAGE_KEY = 'hermes.desktop.collapsed-providers'
+
+/** Set of provider slugs whose model groups are currently collapsed in the
+ * model picker dropdown. Persisted to localStorage globally — this is a
+ * presentation-layer preference, not a per-profile setting.
+ *
+ * We deliberately do NOT prune this set when the active provider catalog
+ * changes (e.g. profile switch, Refresh Models, API key revoked). The catalog
+ * the picker renders is profile-scoped (`getGlobalModelOptions` routes through
+ * `profileScoped()`), so pruning against only the active catalog would delete
+ * a user's collapse preference every time they switch to a profile whose
+ * configured providers don't include it — silently losing state across what
+ * is otherwise a pure presentational toggle.
+ *
+ * Provider slugs come from a small bounded configured set (not user input),
+ * so dead entries in the array cost a few bytes and have no observable effect:
+ * the render loop only visits providers present in the active `groups`, and
+ * `collapsedProviders.includes(slug)` against an absent slug is a no-op.
+ */
+export const $collapsedProviders = persistentAtom(STORAGE_KEY, [], Codecs.stringArray)
+
+/** Toggle a provider slug in/out of the collapsed set. */
+export function toggleCollapsedProvider(slug: string): void {
+ const current = $collapsedProviders.get()
+ $collapsedProviders.set(current.includes(slug) ? current.filter(s => s !== slug) : [...current, slug])
+}
diff --git a/apps/desktop/src/store/session-color.test.ts b/apps/desktop/src/store/session-color.test.ts
index 4506ead26c1c..30bb96c65257 100644
--- a/apps/desktop/src/store/session-color.test.ts
+++ b/apps/desktop/src/store/session-color.test.ts
@@ -4,7 +4,7 @@ import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import { $projects } from './projects'
import { $sessions } from './session'
-import { $sessionColorById, sessionColorFor } from './session-color'
+import { $sessionColorById, $sessionColorOverrides, sessionColorFor, setSessionColorOverride } from './session-color'
let nextId = 0
@@ -48,6 +48,7 @@ function makeProject(id: string, folders: string[], color: null | string): Proje
afterEach(() => {
$sessions.set([])
$projects.set([])
+ $sessionColorOverrides.set({})
})
describe('$sessionColorById', () => {
@@ -86,6 +87,43 @@ describe('$sessionColorById', () => {
})
})
+describe('$sessionColorOverrides', () => {
+ it('an override wins over the inherited project color', () => {
+ const a = makeSession('/www/app', { git_repo_root: '/www/app' })
+
+ $projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')])
+ $sessions.set([a])
+ setSessionColorOverride(a.id, '#ff0000')
+
+ expect($sessionColorById.get()[a.id]).toBe('#ff0000')
+ })
+
+ it('clearing an override falls back to the project color', () => {
+ const a = makeSession('/www/app', { git_repo_root: '/www/app' })
+
+ $projects.set([makeProject('p_app', ['/www/app'], '#4a9eff')])
+ $sessions.set([a])
+
+ setSessionColorOverride(a.id, '#ff0000')
+ expect($sessionColorById.get()[a.id]).toBe('#ff0000')
+
+ setSessionColorOverride(a.id, null)
+ expect($sessionColorById.get()[a.id]).toBe('#4a9eff')
+ })
+
+ it('keys on the durable lineage id so a color survives compression', () => {
+ // The live id rotates on auto-compression; the override is stored against the
+ // lineage root, so the continuation tip still resolves to the same color.
+ const root = makeSession('/x', { id: 'root' })
+ const tip = makeSession('/x', { id: 'tip', _lineage_root_id: 'root' })
+
+ setSessionColorOverride('root', '#abcdef')
+
+ $sessions.set([tip])
+ expect($sessionColorById.get().tip).toBe('#abcdef')
+ })
+})
+
describe('sessionColorFor', () => {
it('reads a single session through the same shared map', () => {
const a = makeSession('/www/app', { git_repo_root: '/www/app' })
diff --git a/apps/desktop/src/store/session-color.ts b/apps/desktop/src/store/session-color.ts
index 6d9c473958df..08f5f079448c 100644
--- a/apps/desktop/src/store/session-color.ts
+++ b/apps/desktop/src/store/session-color.ts
@@ -1,36 +1,63 @@
import { computed } from 'nanostores'
import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-groups'
+import { Codecs, persistentAtom } from '@/lib/persisted'
import { $projects } from '@/store/projects'
-import { $sessions } from '@/store/session'
+import { $sessions, sessionPinId } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
+// Per-session color OVERRIDES — a user-picked color that wins over the inherited
+// project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE
+// lineage id so a color survives auto-compression's session-id rotation. To take
+// this to the TUI later, promote this one atom to a backend SessionInfo.color
+// field — the resolver below and the picker UI stay exactly as they are.
+export const $sessionColorOverrides = persistentAtom>(
+ 'hermes.desktop.sessionColors',
+ {},
+ Codecs.stringRecord
+)
+
+// Set a session's override (null clears it → falls back to the project color).
+export function setSessionColorOverride(durableId: string, color: null | string): void {
+ const prev = $sessionColorOverrides.get()
+
+ if (color) {
+ $sessionColorOverrides.set({ ...prev, [durableId]: color })
+ } else if (durableId in prev) {
+ const next = { ...prev }
+ delete next[durableId]
+ $sessionColorOverrides.set(next)
+ }
+}
+
// The resolved color for every session, keyed by live session id — the ONE
// source of truth both the sidebar rows and the pane tabs read, so the two
-// surfaces can never drift. Recomputed only when the session list or the
-// projects change (both cold atoms; the working/streaming pulse lives in
+// surfaces can never drift. Recomputed only when the session list, projects, or
+// overrides change (all cold atoms; the working/streaming pulse lives in
// $sessionStates, so a busy flip never rebuilds this), and every consumer reads
// it as an O(1) lookup rather than re-deriving membership per render.
//
-// Precedence lives in one place: today a session inherits its project's color;
-// when per-session overrides / agent-set colors land (#66565 layers 2-3), fold
-// them in ABOVE the project fallback here and every surface updates for free.
-export const $sessionColorById = computed([$sessions, $projects], (sessions, projects) => {
- const map: Record = {}
+// Precedence in one place: an explicit per-session override wins over the
+// inherited project color. Agent-set color (#66565 layer 3) slots in here too.
+export const $sessionColorById = computed(
+ [$sessions, $projects, $sessionColorOverrides],
+ (sessions, projects, overrides) => {
+ const map: Record = {}
- for (const session of sessions) {
- const color = sessionProjectColor(session, projects)
+ for (const session of sessions) {
+ const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects)
- if (color) {
- map[session.id] = color
+ if (color) {
+ map[session.id] = color
+ }
}
- }
- return map
-})
+ return map
+ }
+)
// The color for a single session object (the tabs already hold the SessionInfo
// they render, so they resolve through the same map the sidebar reads).
export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined {
- return session ? ($sessionColorById.get()[session.id] ?? undefined) : undefined
+ return session ? $sessionColorById.get()[session.id] : undefined
}
diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts
new file mode 100644
index 000000000000..682ebe423d34
--- /dev/null
+++ b/apps/desktop/src/store/session-states.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest'
+
+import { group, split } from '@/components/pane-shell/tree/model'
+import type { SessionTile } from '@/store/session-states'
+import { orderTilesByTree, selectionHomesToWorkspace } from '@/store/session-states'
+
+const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
+const tilePane = (id: string) => `session-tile:${id}`
+
+describe('orderTilesByTree', () => {
+ it('no-ops (null) without a tree or below two tiles', () => {
+ expect(orderTilesByTree(null, [tile('a'), tile('b')])).toBeNull()
+ expect(orderTilesByTree(group([tilePane('a')]), [tile('a')])).toBeNull()
+ })
+
+ it('reorders tiles to layout-tree encounter order across a split', () => {
+ const tree = split('row', [group(['workspace', tilePane('b')]), group([tilePane('a')])])
+
+ expect(orderTilesByTree(tree, [tile('a'), tile('b')])).toEqual([tile('b'), tile('a')])
+ })
+
+ it('returns null when the array already matches strip order (skip persist)', () => {
+ const tree = split('row', [group([tilePane('b')]), group([tilePane('a')])])
+
+ expect(orderTilesByTree(tree, [tile('b'), tile('a')])).toBeNull()
+ })
+
+ it('sorts not-yet-adopted tiles after placed ones, stably', () => {
+ const tree = group(['workspace', tilePane('b')])
+
+ expect(orderTilesByTree(tree, [tile('a'), tile('b'), tile('c')])).toEqual([tile('b'), tile('a'), tile('c')])
+ })
+})
+
+describe('selectionHomesToWorkspace', () => {
+ const tiles = [tile('a'), tile('b')]
+
+ it('homes for a null selection or a non-tile session', () => {
+ expect(selectionHomesToWorkspace(null, tiles)).toBe(true)
+ expect(selectionHomesToWorkspace('c', tiles)).toBe(true)
+ })
+
+ it('skips homing when the selected id is already an open tile', () => {
+ expect(selectionHomesToWorkspace('a', tiles)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index 869433436bbd..7041d26b3be7 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -19,7 +19,7 @@
import { atom, computed } from 'nanostores'
import type { ClientSessionState } from '@/app/types'
-import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model'
+import { findGroup, findGroupOfPane, type LayoutNode } from '@/components/pane-shell/tree/model'
import {
$activeTreeGroup,
$layoutTree,
@@ -27,6 +27,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
+import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
@@ -44,17 +45,31 @@ import { isSecondaryWindow } from './windows'
export const $sessionStates = atom>({})
-// --- Watchdog: force-clears busy after 8 min of stream silence -------------
-const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
-const sessionWatchdogTimers = new Map>()
+// Stored session ids whose authoritative state is still busy, but whose
+// runtime has produced no state publish for the watchdog window. Silence is
+// not completion: long tool calls can legitimately stay quiet, so this is a
+// presentation hint and never mutates the backend-derived busy state.
+export const $stalledSessionIds = atom([])
-type WatchdogClearFn = (runtimeId: string) => void
-let watchdogClearFn: WatchdogClearFn | null = null
+export function setSessionStalled(storedSessionId: string | null | undefined, stalled: boolean) {
+ if (!storedSessionId) {
+ return
+ }
-export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
- watchdogClearFn = fn
+ const current = $stalledSessionIds.get()
+ const present = current.includes(storedSessionId)
+
+ if (stalled && !present) {
+ $stalledSessionIds.set([...current, storedSessionId])
+ } else if (!stalled && present) {
+ $stalledSessionIds.set(current.filter(id => id !== storedSessionId))
+ }
}
+// --- Watchdog: marks busy sessions quiet after 8 min of stream silence -----
+export const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
+const sessionWatchdogTimers = new Map>()
+
function armWatchdog(runtimeId: string) {
const existing = sessionWatchdogTimers.get(runtimeId)
@@ -66,7 +81,11 @@ function armWatchdog(runtimeId: string) {
runtimeId,
setTimeout(() => {
sessionWatchdogTimers.delete(runtimeId)
- watchdogClearFn?.(runtimeId)
+ const current = $sessionStates.get()[runtimeId]
+
+ if (current?.busy) {
+ setSessionStalled(current.storedSessionId, true)
+ }
}, SESSION_WATCHDOG_TIMEOUT_MS)
)
}
@@ -124,13 +143,19 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
}
clearSettled(previous.storedSessionId)
+ setSessionStalled(previous.storedSessionId, false)
}
- // Watchdog: arm on any busy publish, disarm on idle.
+ // Every busy publish is stream activity: clear the quiet hint and restart
+ // the silence window. A real terminal transition clears both the timer and
+ // any hint, but only that authoritative transition clears working/busy.
if (next.busy) {
+ setSessionStalled(next.storedSessionId, false)
armWatchdog(runtimeId)
} else {
clearWatchdog(runtimeId)
+ setSessionStalled(next.storedSessionId, false)
+ setSessionStalled(previous?.storedSessionId, false)
}
const storedId = next.storedSessionId
@@ -174,6 +199,7 @@ export function dropSessionState(runtimeId: string) {
clearWatchdog(runtimeId)
const current = $sessionStates.get()
+ setSessionStalled(current[runtimeId]?.storedSessionId, false)
if (!(runtimeId in current)) {
return
@@ -195,24 +221,41 @@ export function clearAllSessionStates() {
sessionWatchdogTimers.clear()
settledExpiry.clear()
+ $stalledSessionIds.set([])
$sessionStates.set({})
}
-// Derived per-session status sets. `$sessionStates` already holds `busy` and
-// `needsInput` for every runtime session (written by updateSessionState); these
-// are pure projections of it, not independently maintained atoms. This keeps the
-// data flow one-directional: gateway event → cache → $sessionStates → computed
-// views, eliminating the "projection atom out of sync with cache" bug class.
-export const $workingSessionIds = computed($sessionStates, states =>
+// Derived per-session status sets — pure projections of `$sessionStates` (which
+// holds `busy`/`needsInput` per runtime), keeping the data flow one-directional:
+// gateway event → cache → $sessionStates → computed views.
+//
+// Perf: `$sessionStates` is republished on EVERY message delta (tens/sec during
+// a turn), but these sets only change on busy/needsInput edges. `stableArray`
+// keeps the prior reference when membership is unchanged so `computed` skips the
+// emit — otherwise the whole sidebar + every row re-renders per token.
+const storedIds = (states: Record, pred: (s: ClientSessionState) => boolean) =>
Object.values(states)
- .filter(s => s.busy && s.storedSessionId)
+ .filter(s => pred(s) && s.storedSessionId)
.map(s => s.storedSessionId!)
+
+let workingIds: readonly string[] = []
+export const $workingSessionIds = computed(
+ $sessionStates,
+ states =>
+ (workingIds = stableArray(
+ workingIds,
+ storedIds(states, s => s.busy)
+ ))
)
-export const $attentionSessionIds = computed($sessionStates, states =>
- Object.values(states)
- .filter(s => s.needsInput && s.storedSessionId)
- .map(s => s.storedSessionId!)
+let attentionIds: readonly string[] = []
+export const $attentionSessionIds = computed(
+ $sessionStates,
+ states =>
+ (attentionIds = stableArray(
+ attentionIds,
+ storedIds(states, s => s.needsInput)
+ ))
)
// ---------------------------------------------------------------------------
@@ -233,10 +276,11 @@ export interface SessionTile {
/** Dock against `anchor` on adoption (default right; center = stack). */
dir?: TileDock
/** Pane to dock against (a drop's target zone) — default the workspace.
- * In-memory only: after first adoption the tree remembers placement. */
+ * Persisted so a restart re-docks in place; a stale id falls back to the
+ * workspace (findGroupOfPane misses → the move is skipped). */
anchor?: string
- /** Center docks: stack BEFORE this pane id (`null`/omitted = append) —
- * the strip divider's slot. In-memory, like `anchor`. */
+ /** Center docks: stack BEFORE this pane id (`null`/omitted = append) — the
+ * strip divider's slot. Persisted, like `anchor`; a stale id appends. */
before?: null | string
/** Live runtime id once the tile's resume has bound one. */
runtimeId?: string
@@ -252,16 +296,34 @@ export interface SessionTile {
// "stale runtime after respawn" bugs by construction).
const TILES_KEY = 'hermes.desktop.sessionTiles.v2'
const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1'
+const TILE_PANE_PREFIX = 'session-tile:'
-type StoredTile = Pick
+/** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a
+ * restart / profile swap re-adopts tiles in the same order, not all stacked
+ * right of workspace. */
+type StoredTile = Pick
-const toStored = (t: SessionTile): StoredTile => ({ dir: t.dir, storedSessionId: t.storedSessionId })
+const toStored = (t: SessionTile): StoredTile => ({
+ anchor: t.anchor,
+ before: t.before,
+ dir: t.dir,
+ storedSessionId: t.storedSessionId
+})
function parseTileList(value: unknown): StoredTile[] {
return Array.isArray(value)
? value
.filter((t): t is SessionTile => Boolean(t && typeof (t as SessionTile).storedSessionId === 'string'))
- .map(toStored)
+ .map(t => {
+ const raw = t as SessionTile
+
+ return {
+ anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined,
+ before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined,
+ dir: raw.dir,
+ storedSessionId: raw.storedSessionId
+ }
+ })
: []
}
@@ -390,6 +452,56 @@ export function sessionTileDelegate(): SessionTileDelegate | null {
return delegate
}
+/** Reorder tiles to match layout-tree encounter order (stored ids in the order
+ * their `session-tile:` panes are walked). Restore replays the array through
+ * sequential adoption (each center tile APPENDS after the ones before it), so
+ * array order IS strip order — no `before` stamping needed; a stale `before`
+ * naming an absent pane falls back to append anyway (see insertAtGroup). Tiles
+ * not yet adopted sort after placed ones, stably. Returns `null` when nothing
+ * moves so callers can skip a needless persist. */
+export function orderTilesByTree(
+ tree: LayoutNode | null,
+ tiles: readonly T[]
+): null | T[] {
+ if (!tree || tiles.length < 2) {
+ return null
+ }
+
+ const order: string[] = []
+
+ const walk = (node: LayoutNode) => {
+ if (node.type === 'group') {
+ for (const id of node.panes) {
+ if (id.startsWith(TILE_PANE_PREFIX)) {
+ order.push(id.slice(TILE_PANE_PREFIX.length))
+ }
+ }
+
+ return
+ }
+
+ node.children.forEach(walk)
+ }
+
+ walk(tree)
+
+ const rank = new Map(order.map((id, i) => [id, i]))
+
+ const next = [...tiles].sort(
+ (a, b) => (rank.get(a.storedSessionId) ?? Infinity) - (rank.get(b.storedSessionId) ?? Infinity)
+ )
+
+ return next.some((t, i) => t !== tiles[i]) ? next : null
+}
+
+function syncTileStripOrder() {
+ const next = orderTilesByTree($layoutTree.get(), $sessionTiles.get())
+
+ if (next) {
+ saveTiles(next)
+ }
+}
+
/** Open a tile for a stored session, or MOVE an existing one to the new dock
* (`dir`; `center` = stack into the anchor's zone, `before` = strip slot). The
* move path is what lets a tile's own TAB be dragged like a sidebar row — drop
@@ -410,6 +522,8 @@ export function openSessionTile(
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
saveTiles([...tiles, { anchor, before, dir, storedSessionId }])
+ // Adoption is async via the registry — order sync runs after the move path
+ // below; a brand-new tile's strip slot is already in `before`.
return
}
@@ -421,6 +535,8 @@ export function openSessionTile(
if (target) {
moveTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`, { before: before ?? null, groupId: target, pos: dir })
+ patchSessionTile(storedSessionId, { anchor, before: before ?? undefined, dir })
+ syncTileStripOrder()
}
}
@@ -515,8 +631,6 @@ export function reopenLastClosedTile(): void {
// timer / model) reads these instead of the primary-only atoms.
// ---------------------------------------------------------------------------
-const TILE_PANE_PREFIX = 'session-tile:'
-
/** Stored id of the focused session (the interacted zone's tile, else the
* primary's selection). Null on a fresh draft. */
export const $focusedStoredSessionId = computed(
@@ -546,12 +660,20 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates]
runtimeId ? states[runtimeId] : undefined
)
-// A PRIMARY navigation (sidebar resume, route change, new chat) moves focus
-// home to the workspace — a previously-clicked tile must not keep owning the
-// titlebar/statusbar readouts for a session switch it had no part in. It also
-// FRONTS the workspace tab: the resumed chat loads in the workspace pane, so a
-// zone parked on a tile tab must switch back or the click looks dead.
-$selectedStoredSessionId.listen(() => {
+/** A PRIMARY navigation (sidebar resume, route change, new chat) homes focus to
+ * the workspace — UNLESS the selected id is already an open TILE, where
+ * `focusOpenSession` owns the move and homing would yank every stacked tile
+ * behind the workspace (A+B "disappear" when switching to C). */
+export const selectionHomesToWorkspace = (selected: null | string, tiles: readonly SessionTile[]): boolean =>
+ !(selected && tiles.some(t => t.storedSessionId === selected))
+
+// Homing also FRONTS the workspace tab: the resumed chat loads in the workspace
+// pane, so a zone parked on a tile tab must switch back or the click looks dead.
+$selectedStoredSessionId.listen(selected => {
+ if (!selectionHomesToWorkspace(selected, $sessionTiles.get())) {
+ return
+ }
+
noteActiveTreeGroup(null)
revealTreePane('workspace')
})
diff --git a/apps/desktop/src/store/session-watchdog.test.ts b/apps/desktop/src/store/session-watchdog.test.ts
index b4b7ca52303b..2b023fd3736d 100644
--- a/apps/desktop/src/store/session-watchdog.test.ts
+++ b/apps/desktop/src/store/session-watchdog.test.ts
@@ -6,12 +6,11 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
import {
$attentionSessionIds,
- $sessionStates,
+ $stalledSessionIds,
$workingSessionIds,
clearAllSessionStates,
getRecentlySettledSessionIds,
- publishSessionState,
- setWatchdogClearFn
+ publishSessionState
} from './session-states'
const WATCHDOG_MS = 8 * 60 * 1000
@@ -24,8 +23,6 @@ describe('session status transitions', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
- // clearAllSessionStates also disarms watchdog timers + drops settle-grace
- // entries, so no leftover state can leak in from a previous test.
clearAllSessionStates()
$unreadFinishedSessionIds.set([])
$selectedStoredSessionId.set(null)
@@ -45,25 +42,18 @@ describe('session status transitions', () => {
const s = state({ busy: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
- // idle → working
- const next = { ...s, busy: true }
- publishSessionState('rt1', next)
+ publishSessionState('rt1', { ...s, busy: true })
expect($workingSessionIds.get()).toContain('s1')
})
it('removes a session from $workingSessionIds when busy transitions to false', () => {
- const s = state({ busy: true, storedSessionId: 's1' })
- publishSessionState('rt1', s)
- // Simulate the working state being set
- const working = { ...s, busy: true }
+ const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
expect($workingSessionIds.get()).toContain('s1')
- // Now transition to idle
- const idle = { ...working, busy: false }
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', { ...working, busy: false })
expect($workingSessionIds.get()).not.toContain('s1')
})
@@ -72,77 +62,63 @@ describe('session status transitions', () => {
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
publishSessionState('rt1', s)
- const next = { ...s, needsInput: true }
- publishSessionState('rt1', next)
+ publishSessionState('rt1', { ...s, needsInput: true })
expect($attentionSessionIds.get()).toContain('s1')
})
it('marks a background session unread when its turn finishes', () => {
$selectedStoredSessionId.set('other-session')
-
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
- const idle = { ...working, busy: false }
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', { ...working, busy: false })
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
})
it('does NOT mark unread when the finishing session is the active one', () => {
$selectedStoredSessionId.set('s1')
-
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
- const idle = { ...working, busy: false }
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', { ...working, busy: false })
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
$selectedStoredSessionId.set('other-session')
-
- const idle = state({ busy: false, storedSessionId: 's1' })
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
expect($unreadFinishedSessionIds.get()).toEqual([])
})
it('grants settle grace when a working session goes idle', () => {
$selectedStoredSessionId.set('other')
-
const working = state({ busy: true, storedSessionId: 's1' })
publishSessionState('rt1', working)
- const idle = { ...working, busy: false }
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', { ...working, busy: false })
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
})
it('does not grant grace on idle→idle re-asserts', () => {
- const idle = state({ busy: false, storedSessionId: 's1' })
- publishSessionState('rt1', idle)
+ publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears settle grace when the session goes busy again', () => {
$selectedStoredSessionId.set('other')
-
const working = state({ busy: true, storedSessionId: 's2' })
publishSessionState('rt1', working)
-
const idle = { ...working, busy: false }
publishSessionState('rt1', idle)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
- // New turn for the same session
- const workingAgain = { ...idle, busy: true }
- publishSessionState('rt1', workingAgain)
+ publishSessionState('rt1', { ...idle, busy: true })
expect(getRecentlySettledSessionIds()).toEqual([])
})
@@ -166,67 +142,61 @@ describe('session watchdog', () => {
$activeSessionId.set(null)
})
- it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
- // Wire a clear fn like use-session-state-cache does in the real app: the
- // watchdog hands us the runtime id, we publish the busy:false state.
- const clearedRuntimeIds: string[] = []
- setWatchdogClearFn(runtimeId => {
- clearedRuntimeIds.push(runtimeId)
- const current = $sessionStates.get()[runtimeId]
+ it('marks a silent session stalled without pretending it finished', () => {
+ publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
- if (current) {
- publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
- }
- })
-
- const working = state({ busy: true, storedSessionId: 's1' })
- publishSessionState('rt1', working)
+ vi.advanceTimersByTime(WATCHDOG_MS)
expect($workingSessionIds.get()).toContain('s1')
-
- // Watchdog fires after 8 min of silence → the wired clear fn runs and the
- // computed working set drops the session. This asserts the timer→callback
- // wiring, not just the projection.
- vi.advanceTimersByTime(WATCHDOG_MS)
-
- expect(clearedRuntimeIds).toEqual(['rt1'])
- expect($workingSessionIds.get()).not.toContain('s1')
-
- setWatchdogClearFn(null)
+ expect($stalledSessionIds.get()).toContain('s1')
})
- it('never fires for a session that settles before the window', () => {
- const clearedRuntimeIds: string[] = []
- setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
-
+ it('clears stalled on new activity and rearms the watchdog', () => {
const working = state({ busy: true, storedSessionId: 's2' })
publishSessionState('rt2', working)
-
- // Session settles before the watchdog window
- const idle = { ...working, busy: false }
- publishSessionState('rt2', idle)
-
vi.advanceTimersByTime(WATCHDOG_MS)
+ expect($stalledSessionIds.get()).toContain('s2')
- // The watchdog was disarmed — the clear fn never ran.
- expect(clearedRuntimeIds).toEqual([])
- expect($workingSessionIds.get()).not.toContain('s2')
+ publishSessionState('rt2', { ...working, awaitingResponse: true })
+ expect($stalledSessionIds.get()).not.toContain('s2')
- setWatchdogClearFn(null)
+ vi.advanceTimersByTime(WATCHDOG_MS - 1)
+ expect($stalledSessionIds.get()).not.toContain('s2')
+ expect($workingSessionIds.get()).toContain('s2')
})
- it('does not fire after clearAllSessionStates disarms every timer', () => {
- const clearedRuntimeIds: string[] = []
- setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
+ it('clears both running and stalled on an authoritative terminal transition', () => {
+ const working = state({ busy: true, storedSessionId: 's3' })
+ publishSessionState('rt3', working)
+ vi.advanceTimersByTime(WATCHDOG_MS)
+ expect($stalledSessionIds.get()).toContain('s3')
- publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
- clearAllSessionStates()
+ publishSessionState('rt3', { ...working, busy: false })
+ expect($workingSessionIds.get()).not.toContain('s3')
+ expect($stalledSessionIds.get()).not.toContain('s3')
+ })
+
+ it('never marks a session stalled when it settles before the window', () => {
+ const working = state({ busy: true, storedSessionId: 's4' })
+ publishSessionState('rt4', working)
+ publishSessionState('rt4', { ...working, busy: false })
vi.advanceTimersByTime(WATCHDOG_MS)
- expect(clearedRuntimeIds).toEqual([])
+ expect($workingSessionIds.get()).not.toContain('s4')
+ expect($stalledSessionIds.get()).not.toContain('s4')
+ })
- setWatchdogClearFn(null)
+ it('clears stalled state and disarms timers on a gateway wipe', () => {
+ publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
+ vi.advanceTimersByTime(WATCHDOG_MS)
+ expect($stalledSessionIds.get()).toEqual(['s1'])
+
+ clearAllSessionStates()
+ vi.advanceTimersByTime(WATCHDOG_MS)
+
+ expect($workingSessionIds.get()).toEqual([])
+ expect($stalledSessionIds.get()).toEqual([])
})
})
diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts
index 07ce07c02a47..dbfd7dbc7995 100644
--- a/apps/desktop/src/store/session.ts
+++ b/apps/desktop/src/store/session.ts
@@ -381,6 +381,19 @@ export const setCurrentModelSource = (source: ComposerModelSource) => {
$currentModelSource.set(source)
}
+// Monotonic intent token for async default refreshes. A profile/config request
+// may start before the user opens the picker and finish after their click; the
+// token lets that older response stand down even when the selected value is
+// unchanged (value comparisons alone cannot detect re-selecting the same row).
+let composerSelectionGeneration = 0
+
+export const getComposerSelectionGeneration = (): number => composerSelectionGeneration
+
+export const markComposerSelectionManual = (): void => {
+ composerSelectionGeneration += 1
+ setCurrentModelSource('manual')
+}
+
export const setCurrentReasoningEffort = (next: Updater) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts
index 7439a8a8345f..1a0dc7f07923 100644
--- a/apps/desktop/src/store/updates.test.ts
+++ b/apps/desktop/src/store/updates.test.ts
@@ -120,7 +120,7 @@ describe('reportBackendContract', () => {
})
it('dismisses the toast when the backend meets the contract', () => {
- reportBackendContract(3)
+ reportBackendContract(4)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
@@ -160,8 +160,8 @@ describe('reportBackendContract', () => {
lastToast().onDismiss()
notifySpy.mockClear()
- reportBackendContract(3) // backend updated → satisfied, snooze cleared
- reportBackendContract(2) // a later regression must warn immediately
+ reportBackendContract(4) // backend updated → satisfied, snooze cleared
+ reportBackendContract(3) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts
index a7b0bbc8b94e..6c7e1483cf93 100644
--- a/apps/desktop/src/store/updates.ts
+++ b/apps/desktop/src/store/updates.ts
@@ -92,7 +92,8 @@ function isUpdateToastSnoozed(): boolean {
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
// v3: requires approvals.mode config RPCs and session.info reconciliation.
-const REQUIRED_BACKEND_CONTRACT = 3
+// v4: requires explicit Fast-off session creation and session-scoped Fast edits.
+const REQUIRED_BACKEND_CONTRACT = 4
const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
diff --git a/apps/desktop/src/store/workspace-events.ts b/apps/desktop/src/store/workspace-events.ts
index 174427a28596..4e75bf7b3e45 100644
--- a/apps/desktop/src/store/workspace-events.ts
+++ b/apps/desktop/src/store/workspace-events.ts
@@ -9,6 +9,33 @@ import { atom } from 'nanostores'
export const $workspaceChangeTick = atom(0)
+// What changed since the last consume. The file tree targets `dirs` (surgical
+// subtree re-reads) and only falls back to a whole-tree rescan when `full` is
+// set — an opaque mutation (a terminal command, or a path we can't resolve to
+// the tree's absolute ids) whose touched paths we can't enumerate. Coarse
+// subscribers (coding rail, review) ignore this and just react to the tick.
+let pendingDirs = new Set()
+let pendingFull = false
+
+/** Drain the accumulated change since the previous call (the tree's consumer). */
+export function consumeWorkspaceChange(): { dirs: string[]; full: boolean } {
+ const change = { dirs: [...pendingDirs], full: pendingFull }
+ pendingDirs = new Set()
+ pendingFull = false
+
+ return change
+}
+
+// Parent dir of an ABSOLUTE path (POSIX or `C:/…`); null for a relative path we
+// can't anchor to the tree — the caller treats null as "rescan to be safe".
+function dirOf(path: string): null | string {
+ const p = path.replace(/\\/g, '/').replace(/\/+$/, '')
+ const absolute = p.startsWith('/') || /^[a-z]:\//i.test(p)
+ const slash = p.lastIndexOf('/')
+
+ return absolute && slash >= 0 ? p.slice(0, slash) : null
+}
+
// Throttle so a burst of edits in one turn coalesces: fire on the leading edge
// for instant feedback, then at most once per window (a trailing fire catches
// the last edit of the burst).
@@ -21,7 +48,17 @@ function fire(): void {
$workspaceChangeTick.set($workspaceChangeTick.get() + 1)
}
-export function notifyWorkspaceChanged(): void {
+/** @param changedPath absolute path a tool touched; omit (or pass a relative /
+ * unknowable path) to force a full-tree rescan. */
+export function notifyWorkspaceChanged(changedPath?: string): void {
+ const dir = changedPath ? dirOf(changedPath) : null
+
+ if (dir) {
+ pendingDirs.add(dir)
+ } else {
+ pendingFull = true
+ }
+
const since = Date.now() - lastFired
if (since >= MIN_INTERVAL_MS) {
@@ -58,3 +95,29 @@ export function toolMayMutateFiles(payload: { name?: unknown; tool?: unknown; in
return MUTATING_TOOL_RE.test(name)
}
+
+// Common arg keys a single-file writer/mover uses for its target. A hit lets the
+// tree target that dir; a miss (terminal, multi-path, odd schema) → full rescan.
+const PATH_ARG_KEYS = ['path', 'file_path', 'filename', 'file', 'target_file', 'new_path', 'dest', 'destination']
+
+/** Best-effort absolute path a finished tool touched, from its args — or
+ * undefined (→ full rescan) for terminal/opaque/multi-path mutations. */
+export function toolChangedPath(payload: { args?: unknown; arguments?: unknown }): string | undefined {
+ const args = payload.args ?? payload.arguments
+
+ if (!args || typeof args !== 'object') {
+ return undefined
+ }
+
+ const record = args as Record
+
+ for (const key of PATH_ARG_KEYS) {
+ const value = record[key]
+
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ }
+
+ return undefined
+}
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index dc2a71f1dffa..fcfae5cf7bde 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -149,6 +149,49 @@ export interface MemoryProviderConfig {
name: string
}
+export interface CustomEndpoint {
+ api_key_preview?: null | string
+ base_url: string
+ context_length?: null | number
+ discover_models: boolean
+ has_api_key: boolean
+ id: string
+ is_current?: boolean
+ model: string
+ models: string[]
+ name: string
+ source?: string
+}
+
+export interface CustomEndpointsResponse {
+ current: {
+ base_url: string
+ model: string
+ provider: string
+ }
+ endpoints: CustomEndpoint[]
+ id?: string
+ ok?: boolean
+}
+
+export interface CustomEndpointUpdate {
+ api_key?: string
+ base_url: string
+ context_length?: number
+ discover_models?: boolean
+ id?: string
+ make_default?: boolean
+ model: string
+ name: string
+}
+
+export interface CustomEndpointValidationResponse {
+ message: string
+ models: string[]
+ ok: boolean
+ reachable: boolean
+}
+
export interface MessagingEnvVarInfo {
advanced: boolean
description: string
@@ -213,6 +256,7 @@ export interface HermesConfig {
display?: {
personality?: string
skin?: string
+ interim_assistant_messages?: boolean
}
terminal?: {
cwd?: string
@@ -717,6 +761,11 @@ export interface ToolProvider {
/** Web toolset only: the backend key written to web.*backend config
* (e.g. 'searxng'). Absent on other toolsets and older backends. */
web_backend?: string
+ /** TTS toolset only: the provider key written to tts.provider when this row
+ * is selected (e.g. 'openai'). Doubles as the config section that holds the
+ * provider's voice/model settings (tts..*). Absent on other toolsets
+ * and older backends. */
+ tts_provider?: string
/** Web toolset only: capabilities this backend can serve. Search-only
* providers (ddgs, brave-free) report ['search']. */
capabilities?: WebCapability[]
diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts
index a671ac3ae971..a780ec2faa52 100644
--- a/apps/desktop/vitest.setup.ts
+++ b/apps/desktop/vitest.setup.ts
@@ -1,6 +1,12 @@
-import '@testing-library/react'
+import { configure } from '@testing-library/react'
// React 19 + Testing Library 16: opt into the act environment so render(),
// fireEvent(), and findBy* queries automatically flush state updates without
// spurious "not wrapped in act(...)" warnings.
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
+
+// findBy*/waitFor default to a 1000ms deadline — too tight for async-heavy
+// panels (radix menus, refetch chains) when the full suite runs under xdist
+// CPU contention in CI. Success still resolves the instant the node appears;
+// the wider deadline only absorbs a starved runner, killing timing flakes.
+configure({ asyncUtilTimeout: 5000 })
diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts
index b083d8e0e1a0..cff91305f27f 100644
--- a/apps/shared/src/json-rpc-gateway.ts
+++ b/apps/shared/src/json-rpc-gateway.ts
@@ -3,6 +3,7 @@ export type GatewayEventName =
| 'session.info'
| 'message.start'
| 'message.delta'
+ | 'message.interim'
| 'message.complete'
| 'thinking.delta'
| 'reasoning.delta'
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 384b220236ef..8b93a148fb65 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -583,28 +583,27 @@ memory:
# Session Reset Policy (Messaging Platforms)
# =============================================================================
# Controls when messaging sessions (Telegram, Discord, WhatsApp, Slack) are
-# automatically cleared. Without resets, conversation context grows indefinitely
-# which increases API costs with every message.
+# automatically cleared. Default is "none": sessions never auto-reset —
+# conversation context lives until you /reset or /new manually, or context
+# compression kicks in. Opt in to automatic resets if you prefer sessions to
+# clear on a schedule (long-lived context increases API cost per message,
+# though prompt caching and compression keep this manageable).
#
-# When a reset triggers, the agent first saves important information to its
-# persistent memory — but the conversation context is wiped. The agent starts
-# fresh but retains learned facts via its memory system.
-#
-# Users can always manually reset with /reset or /new in chat.
+# When an automatic reset triggers, the agent first saves important
+# information to its persistent memory — but the conversation context is
+# wiped. The agent starts fresh but retains learned facts via its memory
+# system.
#
# Modes:
-# "both" - Reset on EITHER inactivity timeout or daily boundary (recommended)
-# "idle" - Reset only after N minutes of inactivity
-# "daily" - Reset only at a fixed hour each day
-# "none" - Never auto-reset; context lives until /reset or compression kicks in
-#
-# When a reset triggers, the agent gets one turn to save important memories and
-# skills before the context is wiped. Persistent memory carries across sessions.
+# "none" - Never auto-reset (default); context lives until /reset or compression
+# "idle" - Reset after N minutes of inactivity
+# "daily" - Reset at a fixed hour each day
+# "both" - Reset on EITHER inactivity timeout or daily boundary
#
session_reset:
- mode: both # "both", "idle", "daily", or "none"
- idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
- at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
+ mode: none # "none", "idle", "daily", or "both"
+ idle_minutes: 1440 # Inactivity timeout in minutes (used by "idle"/"both")
+ at_hour: 4 # Daily reset hour, 0-23 local time (used by "daily"/"both")
# Maximum number of simultaneously active chat sessions across CLI, TUI,
# dashboard chat, and messaging gateway. Set to null, 0, or omit to allow
@@ -1182,11 +1181,14 @@ display:
# cleanup_progress: true
cleanup_progress: false
- # Gateway-only natural mid-turn assistant updates.
- # When true, completed assistant status messages are sent as separate chat
- # messages. This is independent of tool_progress and gateway streaming.
- # true: Send mid-turn assistant updates as separate messages (default)
- # false: Only send the final response
+ # Natural mid-turn assistant updates.
+ # On gateway platforms, when true, completed assistant status messages are
+ # sent as separate chat messages. On the Desktop app, when true, mid-turn
+ # assistant narration streamed between tool calls is kept in the transcript
+ # instead of the bubble collapsing to only the final message on completion.
+ # Independent of tool_progress and gateway streaming.
+ # true: Keep/send mid-turn assistant updates (default)
+ # false: Only keep/send the final response
interim_assistant_messages: true
# Gateway-only long-running status heartbeats.
diff --git a/cli.py b/cli.py
index 16f34b30cc92..7d355a74a6e0 100644
--- a/cli.py
+++ b/cli.py
@@ -119,6 +119,52 @@ def format_duration_compact(*args, **kwargs):
return f"{days:.1f}d"
+# Cached reverse map of config.yaml ``model_aliases:`` so the TUI can show
+# friendly names instead of full Palantir RIDs / long catalog IDs. Built
+# lazily on first call; cache is process-lifetime (config is read once at
+# session start, so further invalidation is unnecessary).
+_REVERSE_ALIAS_CACHE: dict[str, str] | None = None
+
+
+def _reverse_alias_for_display(model_name: str) -> str:
+ """Return the shortest configured alias for ``model_name``, or ``model_name``.
+
+ Looks up both ``model_aliases:`` (dict-based, full DirectAlias entries)
+ and ``model.aliases:`` (string-based, set via ``hermes config set``)
+ from config.yaml. Multiple aliases pointing at the same model — the
+ shortest wins, so ``opus47`` beats ``palantir-claude47``.
+ """
+ global _REVERSE_ALIAS_CACHE
+ if not model_name:
+ return model_name
+ if _REVERSE_ALIAS_CACHE is None:
+ rmap: dict[str, str] = {}
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config() or {}
+ ma = cfg.get("model_aliases")
+ if isinstance(ma, dict):
+ for alias, entry in ma.items():
+ if isinstance(entry, dict):
+ m = str(entry.get("model", "") or "").strip()
+ if m and (m not in rmap or len(alias) < len(rmap[m])):
+ rmap[m] = alias
+ mdl = cfg.get("model", {}) or {}
+ if isinstance(mdl, dict):
+ simple = mdl.get("aliases")
+ if isinstance(simple, dict):
+ for alias, val in simple.items():
+ if isinstance(val, str) and val.strip():
+ v = val.strip()
+ m = v.split("/", 1)[1] if "/" in v else v
+ if m and (m not in rmap or len(alias) < len(rmap[m])):
+ rmap[m] = alias
+ except Exception:
+ pass
+ _REVERSE_ALIAS_CACHE = rmap
+ return _REVERSE_ALIAS_CACHE.get(model_name, model_name)
+
+
def format_token_count_compact(*args, **kwargs):
value = int(args[0] if args else kwargs.get("value", 0))
abs_value = abs(value)
@@ -4579,7 +4625,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# _try_activate_fallback() switches provider/model.
agent = getattr(self, "agent", None)
model_name = (getattr(agent, "model", None) or self.model or "unknown")
- model_short = model_name.split("/")[-1] if "/" in model_name else model_name
+ # Friendly display: prefer reverse-alias from config.yaml ``model_aliases:``
+ # before slash/length truncation. This turns long Palantir RIDs like
+ # ``ri.language-model-service..language-model.anthropic-claude-4-7-opus``
+ # into the user's chosen short name (e.g. ``opus-4.7``) in the status bar.
+ model_short = _reverse_alias_for_display(model_name)
+ if model_short == model_name:
+ model_short = model_name.split("/")[-1] if "/" in model_name else model_name
+ # Strip Palantir RID prefixes via the shared display formatter so
+ # this site and ``ModelSwitchResult`` confirmation can't drift.
+ from hermes_cli.model_switch import format_model_for_display
+ model_short = format_model_for_display(model_short)
if model_short.endswith(".gguf"):
model_short = model_short[:-5]
if len(model_short) > 26:
@@ -7102,11 +7158,77 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self.conversation_history = []
self._pending_title = None
self._resumed = False
+ self.reasoning_config = _parse_reasoning_config(
+ CLI_CONFIG["agent"].get("reasoning_effort", "")
+ )
+ # /new is a full conversation boundary: session-scoped runtime
+ # overrides (/model --session, /fast, one-turn restores) do not carry
+ # forward. Re-derive model/provider and service tier from config.yaml
+ # so a session-only switch never leaks into the next session (#48055,
+ # #23131).
+ self._pending_one_turn_model_restore = None
+ self.service_tier = _parse_service_tier_config(
+ CLI_CONFIG["agent"].get("service_tier", "")
+ )
+ _model_config = CLI_CONFIG.get("model", {})
+ _config_model = (
+ (_model_config.get("default") or _model_config.get("model") or "")
+ if isinstance(_model_config, dict)
+ else (_model_config or "")
+ )
+ if _config_model and _config_model != getattr(self, "model", None):
+ _config_provider = (
+ _model_config.get("provider", "")
+ if isinstance(_model_config, dict)
+ else ""
+ )
+ try:
+ from hermes_cli.model_switch import switch_model as _switch_model
+
+ _reset_result = _switch_model(
+ raw_input=_config_model,
+ current_provider=self.provider or "",
+ current_model=self.model or "",
+ current_base_url=self.base_url or "",
+ current_api_key=self.api_key or "",
+ is_global=False,
+ explicit_provider=_config_provider or "",
+ )
+ if _reset_result.success:
+ if self.agent:
+ self.agent.switch_model(
+ new_model=_reset_result.new_model,
+ new_provider=_reset_result.target_provider,
+ api_key=_reset_result.api_key,
+ base_url=_reset_result.base_url,
+ api_mode=_reset_result.api_mode,
+ )
+ self.model = _reset_result.new_model
+ self.provider = _reset_result.target_provider
+ self.requested_provider = _reset_result.target_provider
+ self._explicit_api_key = _reset_result.api_key
+ self._explicit_base_url = _reset_result.base_url
+ if _reset_result.api_key:
+ self.api_key = _reset_result.api_key
+ if _reset_result.base_url:
+ self.base_url = _reset_result.base_url
+ if _reset_result.api_mode:
+ self.api_mode = _reset_result.api_mode
+ if not silent:
+ _cprint(
+ f" (model reset to config default: "
+ f"{_reset_result.new_model})"
+ )
+ except Exception:
+ # Best-effort: an unreachable config default must never block
+ # /new. The session keeps the current working model.
+ logger.debug("/new model reset to config default failed", exc_info=True)
_sync_process_session_id(self.session_id)
if self.agent:
self.agent.session_id = self.session_id
self.agent.session_start = self.session_start
+ self.agent.reasoning_config = self.reasoning_config
self.agent.reset_session_state()
if hasattr(self.agent, "_last_flushed_db_idx"):
self.agent._last_flushed_db_idx = 0
@@ -8003,14 +8125,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
)
return
+ from hermes_cli.model_switch import format_model_for_display
+ _display_old = format_model_for_display(old_model)
+ _display_new = format_model_for_display(result.new_model)
+
self._pending_model_switch_note = (
- f"[Note: model was just switched from {old_model} to {result.new_model} "
+ f"[Note: model was just switched from {_display_old} to {_display_new} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
provider_label = result.provider_label or result.target_provider
- _cprint(f" ✓ Model switched: {result.new_model}")
+ _cprint(f" ✓ Model switched: {_display_new}")
_cprint(f" Provider: {provider_label}")
# Context: always resolve via the provider-aware chain so Codex OAuth,
@@ -8136,16 +8262,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
Supports:
/model — show current model + usage hints
- /model — switch model (persists by default)
+ /model — switch model (this session only)
/model --once — switch for the next turn only
- /model --session — switch for this session only
- /model --global — switch and persist (explicit)
+ /model --session — switch for this session only (explicit)
+ /model --global — switch and persist to config.yaml
/model --provider — switch provider + model
/model --provider — switch to provider, auto-detect model
- Persistence defaults to on (``model.persist_switch_by_default`` in
- config.yaml, default True). Use ``--session`` for this CLI session or
- ``--once`` for the next turn only.
+ Persistence defaults to off (``model.persist_switch_by_default`` in
+ config.yaml, default False — switches are session-scoped). Use
+ ``--global`` to persist, or ``--once`` for the next turn only.
"""
from hermes_cli.model_switch import (
switch_model,
@@ -8172,11 +8298,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if one_turn and not model_input and not explicit_provider:
_cprint(" ✗ /model --once requires a model or provider.")
return
- # Resolve the effective persistence once: --session overrides the
- # config-gated default, --global forces persist, otherwise defer to
- # model.persist_switch_by_default (defaults to True so /model survives
- # across sessions).
- persist_global = resolve_persist_behavior(is_global_flag, is_session, is_once=one_turn)
+ # Resolve the effective persistence once: --global forces persist,
+ # --session/--once force session-scope, otherwise defer to
+ # model.persist_switch_by_default (defaults to False so /model is
+ # session-scoped unless the user opts in).
+ persist_global = resolve_persist_behavior(
+ is_global_flag, is_session, is_once=one_turn,
+ explicit_provider=explicit_provider,
+ )
# --refresh: wipe the on-disk picker cache before building the
# provider list. Forces a live re-fetch of every authed provider's
@@ -8217,7 +8346,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
try:
if ctx is None:
raise RuntimeError("inventory context unavailable")
- providers = build_models_payload(ctx)["providers"]
+ providers = build_models_payload(
+ ctx,
+ probe_custom_providers=force_refresh,
+ probe_current_custom_provider=not force_refresh,
+ )["providers"]
except Exception:
providers = []
@@ -8330,8 +8463,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Store a note to prepend to the next user message so the model
# knows a switch occurred (avoids injecting system messages mid-history
# which breaks providers and prompt caching).
+ from hermes_cli.model_switch import format_model_for_display
+ _display_old = format_model_for_display(old_model)
+ _display_new = format_model_for_display(result.new_model)
+
self._pending_model_switch_note = (
- f"[Note: model was just switched from {old_model} to {result.new_model} "
+ f"[Note: model was just switched from {_display_old} to {_display_new} "
f"via {result.provider_label or result.target_provider}. "
f"{'This override applies to the next turn only. ' if one_turn else ''}"
f"Adjust your self-identification accordingly.]"
@@ -8343,7 +8480,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Display confirmation with full metadata
provider_label = result.provider_label or result.target_provider
- _cprint(f" ✓ Model switched: {result.new_model}")
+ _cprint(f" ✓ Model switched: {_display_new}")
_cprint(f" Provider: {provider_label}")
# Context: always resolve via the provider-aware chain so Codex OAuth,
@@ -9995,13 +10132,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
print(f" Error generating insights: {e}")
def _check_config_mcp_changes(self) -> None:
- """Detect mcp_servers changes in config.yaml and auto-reload MCP connections.
+ """Detect mcp_servers changes in config.yaml and react.
Called from process_loop every CONFIG_WATCH_INTERVAL seconds.
Compares config.yaml mtime + mcp_servers section against the last
- known state. When a change is detected, triggers _reload_mcp() and
- informs the user so they know the tool list has been refreshed.
+ known state. When a change is detected:
+
+ * By default (``mcp.auto_reload_on_config_change: true``) it
+ auto-triggers ``_reload_mcp()`` and informs the user — legacy
+ behaviour from #1474.
+ * When opted out (``mcp.auto_reload_on_config_change: false``) it
+ does NOT reload. Instead it notifies the user that the config
+ changed and that they can apply it with ``/reload-mcp`` — while
+ warning that ``/reload-mcp`` rebuilds the tool surface and
+ **invalidates the provider prompt cache** (the next message
+ re-sends the full input prefix, expensive on long-context /
+ high-reasoning models). This stops silent cache-breaking reloads
+ when config.yaml is rewritten frequently by external tooling or
+ other Hermes instances.
"""
+
import yaml as _yaml
CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls
@@ -10033,10 +10183,50 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return
new_mcp = new_cfg.get("mcp_servers") or {}
+ # Expand ${VAR} templates so the comparison is consistent with the
+ # init snapshot (self._config_mcp_servers), which was populated from
+ # the deep-merged + expanded config. Without this, any
+ # save_config_value() that rewrites config.yaml (even for unrelated
+ # keys) triggers a false-positive MCP reload because the raw yaml
+ # still has "${POWERMEM_API_KEY}" while the snapshot has the
+ # expanded value.
+ from hermes_cli.config import _expand_env_vars
+ new_mcp = _expand_env_vars(new_mcp)
if new_mcp == self._config_mcp_servers:
return # mcp_servers unchanged (some other section was edited)
+ # Detected a change in the mcp_servers section. By default we
+ # auto-reload (legacy behaviour), but if the user has opted out we
+ # notify instead of reloading — because every reload rebuilds the
+ # agent tool surface and INVALIDATES the provider prompt cache (the
+ # next message re-sends the full input prefix, which is expensive on
+ # long-context / high-reasoning models).
+ #
+ # The toggle is the top-level ``mcp.auto_reload_on_config_change``
+ # key (see DEFAULT_CONFIG). Read it from the config we just parsed
+ # so the user can flip it in the same edit that changes mcp_servers;
+ # missing key means default-on.
+ _mcp_cfg = new_cfg.get("mcp")
+ _auto = (
+ _mcp_cfg.get("auto_reload_on_config_change", True)
+ if isinstance(_mcp_cfg, dict)
+ else True
+ )
+
self._config_mcp_servers = new_mcp
+
+ if not _auto:
+ # Notify the user that the config changed but do NOT auto-reload.
+ # They can apply the new settings on their own terms with
+ # /reload-mcp — which we explicitly warn may invalidate the cache.
+ print()
+ print("🔄 MCP server config changed — reload skipped (auto-reload disabled).")
+ print(" New settings are NOT applied yet. To apply them now, run:")
+ print(" /reload-mcp")
+ print(" ⚠️ Note: /reload-mcp rebuilds the tool set and invalidates the")
+ print(" provider prompt cache (next message re-sends full input tokens).")
+ return
+
# Notify user and reload. Run in a separate thread with a hard
# timeout so a hung MCP server cannot block the process_loop
# indefinitely (which would freeze the entire TUI).
@@ -13043,8 +13233,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# --- /model picker modal ---
if self._model_picker_state:
try:
- # Picker selections persist by default (same default as
- # /model ); honour model.persist_switch_by_default.
+ # Picker selections follow the same session-scoped default
+ # as /model ; honour model.persist_switch_by_default.
from hermes_cli.model_switch import resolve_persist_behavior
self._handle_model_picker_selection(
diff --git a/contributors/emails/almurat@Almurats-MacBook-Pro.local b/contributors/emails/almurat@Almurats-MacBook-Pro.local
new file mode 100644
index 000000000000..fbd1de9e41e6
--- /dev/null
+++ b/contributors/emails/almurat@Almurats-MacBook-Pro.local
@@ -0,0 +1 @@
+Almurat123
diff --git a/contributors/emails/and@appz.cloud b/contributors/emails/and@appz.cloud
new file mode 100644
index 000000000000..bce725d4a89b
--- /dev/null
+++ b/contributors/emails/and@appz.cloud
@@ -0,0 +1 @@
+logical-and
diff --git a/contributors/emails/ariel@vortexradar.com b/contributors/emails/ariel@vortexradar.com
new file mode 100644
index 000000000000..f6e82d089f89
--- /dev/null
+++ b/contributors/emails/ariel@vortexradar.com
@@ -0,0 +1 @@
+vortexopenclaw
diff --git a/contributors/emails/asscan@189.cn b/contributors/emails/asscan@189.cn
new file mode 100644
index 000000000000..b7d4fdbbc346
--- /dev/null
+++ b/contributors/emails/asscan@189.cn
@@ -0,0 +1 @@
+asscan
diff --git a/contributors/emails/austinpickett@users.noreply.github.com b/contributors/emails/austinpickett@users.noreply.github.com
new file mode 100644
index 000000000000..d30e8a7fe8a1
--- /dev/null
+++ b/contributors/emails/austinpickett@users.noreply.github.com
@@ -0,0 +1,2 @@
+austinpickett
+# PR #67730 salvage of #67643
diff --git a/contributors/emails/dan.brunsdon@gmail.com b/contributors/emails/dan.brunsdon@gmail.com
new file mode 100644
index 000000000000..e10db5b0306d
--- /dev/null
+++ b/contributors/emails/dan.brunsdon@gmail.com
@@ -0,0 +1 @@
+brunz-me
diff --git a/contributors/emails/deepujain@gmail.com b/contributors/emails/deepujain@gmail.com
new file mode 100644
index 000000000000..dbf1b75f3546
--- /dev/null
+++ b/contributors/emails/deepujain@gmail.com
@@ -0,0 +1 @@
+deepujain
diff --git a/contributors/emails/dhravya@supermemory.com b/contributors/emails/dhravya@supermemory.com
new file mode 100644
index 000000000000..91f221be6ae2
--- /dev/null
+++ b/contributors/emails/dhravya@supermemory.com
@@ -0,0 +1 @@
+Dhravya
diff --git a/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com b/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com
new file mode 100644
index 000000000000..fb26e8fb79d1
--- /dev/null
+++ b/contributors/emails/emilio.jesus.lasheras.romero@nttdata.com
@@ -0,0 +1,2 @@
+elashera
+# PR #42745 salvage -> #67759
diff --git a/contributors/emails/gijs@digitalbase.eu b/contributors/emails/gijs@digitalbase.eu
new file mode 100644
index 000000000000..ae8639955259
--- /dev/null
+++ b/contributors/emails/gijs@digitalbase.eu
@@ -0,0 +1 @@
+digitalbase
diff --git a/contributors/emails/git@gottz.de b/contributors/emails/git@gottz.de
new file mode 100644
index 000000000000..447754a922a3
--- /dev/null
+++ b/contributors/emails/git@gottz.de
@@ -0,0 +1 @@
+GottZ
diff --git a/contributors/emails/githubespresso407@users.noreply.github.com b/contributors/emails/githubespresso407@users.noreply.github.com
new file mode 100644
index 000000000000..9a4ca8c9201d
--- /dev/null
+++ b/contributors/emails/githubespresso407@users.noreply.github.com
@@ -0,0 +1 @@
+githubespresso407
diff --git a/contributors/emails/hanqshih@gmail.com b/contributors/emails/hanqshih@gmail.com
new file mode 100644
index 000000000000..3f5fc999b5cf
--- /dev/null
+++ b/contributors/emails/hanqshih@gmail.com
@@ -0,0 +1 @@
+M1racleShih
diff --git a/contributors/emails/info@datachainsystems.com b/contributors/emails/info@datachainsystems.com
new file mode 100644
index 000000000000..4a3f3f085632
--- /dev/null
+++ b/contributors/emails/info@datachainsystems.com
@@ -0,0 +1 @@
+datachainsystems
diff --git a/contributors/emails/jake.tracey@noice.net.au b/contributors/emails/jake.tracey@noice.net.au
new file mode 100644
index 000000000000..d6001e9b5204
--- /dev/null
+++ b/contributors/emails/jake.tracey@noice.net.au
@@ -0,0 +1 @@
+jaketracey
diff --git a/contributors/emails/jasonfang1993@users.noreply.github.com b/contributors/emails/jasonfang1993@users.noreply.github.com
new file mode 100644
index 000000000000..71228adf3276
--- /dev/null
+++ b/contributors/emails/jasonfang1993@users.noreply.github.com
@@ -0,0 +1 @@
+JasonFang1993
diff --git a/contributors/emails/joezhang@outlook.com b/contributors/emails/joezhang@outlook.com
new file mode 100644
index 000000000000..44792b6283df
--- /dev/null
+++ b/contributors/emails/joezhang@outlook.com
@@ -0,0 +1 @@
+JacketPants
diff --git a/contributors/emails/kshitij@users.noreply.github.com b/contributors/emails/kshitij@users.noreply.github.com
new file mode 100644
index 000000000000..c7510483c049
--- /dev/null
+++ b/contributors/emails/kshitij@users.noreply.github.com
@@ -0,0 +1 @@
+kshitijk4poor
diff --git a/contributors/emails/kubolko@users.noreply.github.com b/contributors/emails/kubolko@users.noreply.github.com
new file mode 100644
index 000000000000..e89827cafebb
--- /dev/null
+++ b/contributors/emails/kubolko@users.noreply.github.com
@@ -0,0 +1 @@
+kubolko
diff --git a/contributors/emails/liqiping@msh.team b/contributors/emails/liqiping@msh.team
new file mode 100644
index 000000000000..9fb31ce647df
--- /dev/null
+++ b/contributors/emails/liqiping@msh.team
@@ -0,0 +1 @@
+chouqin
diff --git a/contributors/emails/lucas.fernandes.df@gmail.com b/contributors/emails/lucas.fernandes.df@gmail.com
new file mode 100644
index 000000000000..516ed2c38c69
--- /dev/null
+++ b/contributors/emails/lucas.fernandes.df@gmail.com
@@ -0,0 +1 @@
+isfttr
diff --git a/contributors/emails/ly-wang19@users.noreply.github.com b/contributors/emails/ly-wang19@users.noreply.github.com
new file mode 100644
index 000000000000..0f51e0db43b8
--- /dev/null
+++ b/contributors/emails/ly-wang19@users.noreply.github.com
@@ -0,0 +1 @@
+ly-wang19
diff --git a/contributors/emails/patrickmuller@outlook.com b/contributors/emails/patrickmuller@outlook.com
new file mode 100644
index 000000000000..a6636660e5b8
--- /dev/null
+++ b/contributors/emails/patrickmuller@outlook.com
@@ -0,0 +1 @@
+patrick-muller
diff --git a/contributors/emails/pouya.ataei.7@gmail.com b/contributors/emails/pouya.ataei.7@gmail.com
new file mode 100644
index 000000000000..4bcda9e30228
--- /dev/null
+++ b/contributors/emails/pouya.ataei.7@gmail.com
@@ -0,0 +1 @@
+Polyhistor
diff --git a/contributors/emails/punyko8@users.noreply.github.com b/contributors/emails/punyko8@users.noreply.github.com
new file mode 100644
index 000000000000..7c3f6a04ab52
--- /dev/null
+++ b/contributors/emails/punyko8@users.noreply.github.com
@@ -0,0 +1 @@
+Punyko8
diff --git a/contributors/emails/valda68k@gmail.com b/contributors/emails/valda68k@gmail.com
new file mode 100644
index 000000000000..0808f223ec2b
--- /dev/null
+++ b/contributors/emails/valda68k@gmail.com
@@ -0,0 +1,2 @@
+valda
+# PR #61713 salvage
diff --git a/cron/scheduler.py b/cron/scheduler.py
index 26b7eb517125..c5957070021e 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -3178,6 +3178,11 @@ def run_job(
# example DeepSeek) for cron jobs that do not pin provider/model.
runtime_kwargs = {
"requested": job.get("provider"),
+ # Derive provider-specific api_mode from the model this job
+ # will actually run (per-job pin > env > config default), not
+ # the stale persisted default — mirrors the fallback path
+ # below, which already passes its fb_model.
+ "target_model": model,
}
if job.get("base_url"):
runtime_kwargs["explicit_base_url"] = job.get("base_url")
diff --git a/docs/session-lifecycle.md b/docs/session-lifecycle.md
index fbca305c54b6..3c9bb4ee2824 100644
--- a/docs/session-lifecycle.md
+++ b/docs/session-lifecycle.md
@@ -628,7 +628,7 @@ When a session expires:
```yaml
session_reset:
- mode: both # none | idle | daily | both
+ mode: none # none (default) | idle | daily | both
at_hour: 4 # daily reset hour (local time)
idle_minutes: 1440 # idle timeout (24h)
notify: true # notify user on auto-reset
diff --git a/gateway/config.py b/gateway/config.py
index bc2c92387632..cc80cda40155 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -74,6 +74,23 @@ def _env_multiplex_profiles_override() -> "bool | None":
return None
+def _normalize_transport_token(value: Any) -> str:
+ """Normalize a streaming transport/mode value to a canonical token.
+
+ Handles the YAML 1.1 boolean quirk where bare ``on`` / ``off`` parse to
+ Python ``True`` / ``False`` (see ``gateway/display_config.py`` ``_normalise``).
+ Without this, ``mode: off`` arrives as boolean ``False`` and stringifying it
+ yields ``"false"`` instead of the advertised ``"off"``, so streaming would be
+ enabled instead of disabled. Booleans map to ``"auto"`` (True) / ``"off"``
+ (False); anything else is lower-cased, defaulting to ``"auto"``.
+ """
+ if value is None:
+ return "auto"
+ if isinstance(value, bool):
+ return "auto" if value else "off"
+ return str(value).strip().lower() or "auto"
+
+
def _coerce_float(value: Any, default: float) -> float:
"""Coerce numeric config values, falling back on malformed input."""
if value is None:
@@ -717,9 +734,41 @@ class StreamingConfig:
def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
if not isinstance(data, dict) or not data:
return cls()
+
+ # ``mode`` is an ergonomic alias for the transport that ALSO implies
+ # ``enabled``. A config like ``streaming: {mode: auto}`` reads as
+ # "turn streaming on, transport=auto" — matching the natural intent
+ # of someone enabling streaming without also spelling out
+ # ``enabled: true``. Without this, ``mode`` was silently ignored and
+ # streaming stayed disabled (``enabled`` defaults to False), which is
+ # a surprising footgun: the whole reply buffers and sends at once.
+ # ``mode: off`` disables streaming; an explicit ``enabled`` key always
+ # wins so callers can force either state.
+ #
+ # ``transport`` alone does NOT imply ``enabled``: ``streaming.enabled``
+ # is the documented master switch (see website/docs/user-guide/
+ # configuration.md), so a bare ``transport`` only selects HOW to stream
+ # once streaming is on. Only the ``mode`` alias flips ``enabled``.
+ raw_transport = data.get("transport")
+ raw_mode = data.get("mode")
+ # Normalize both through the same helper so YAML's bare ``off``/``on``
+ # (parsed as bool False/True) become canonical tokens rather than
+ # ``"false"``/``"true"``.
+ picked = raw_transport if raw_transport is not None else raw_mode
+ transport = _normalize_transport_token(picked)
+
+ if "enabled" in data:
+ enabled = _coerce_bool(data.get("enabled"), False)
+ elif raw_mode is not None:
+ # The ``mode`` alias (and only ``mode``) infers enabled:
+ # ``off`` disables, anything else enables.
+ enabled = _normalize_transport_token(raw_mode) != "off"
+ else:
+ enabled = False
+
return cls(
- enabled=_coerce_bool(data.get("enabled"), False),
- transport=data.get("transport", "auto"),
+ enabled=enabled,
+ transport=transport,
edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
),
@@ -1178,13 +1227,29 @@ def load_gateway_config() -> GatewayConfig:
from hermes_cli import managed_scope
yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg)
+ # Shared nested-fallback source: settings meant to be top-level
+ # keys are also accepted when a user nests them under `gateway:`
+ # (e.g. via `hermes config set gateway. ...`, which naturally
+ # produces that shape). Every key below mirrors the precedent
+ # already established for gateway.multiplex_profiles/streaming/
+ # write_sessions_json: top-level wins, nested gateway.* falls back.
+ gateway_section = yaml_cfg.get("gateway")
+
# Map config.yaml keys → GatewayConfig.from_dict() schema.
# Each key overwrites whatever gateway.json may have set.
+ # Precedence contract: key-presence at the TOP LEVEL wins; the
+ # nested gateway.* form is consulted only when the top-level key
+ # is absent (not merely falsy/mistyped), so a present-but-empty
+ # top-level value is never silently replaced by the nested one.
sr = yaml_cfg.get("session_reset")
+ if "session_reset" not in yaml_cfg and isinstance(gateway_section, dict):
+ sr = gateway_section.get("session_reset")
if sr and isinstance(sr, dict):
gw_data["default_reset_policy"] = sr
qc = yaml_cfg.get("quick_commands")
+ if qc is None and isinstance(gateway_section, dict):
+ qc = gateway_section.get("quick_commands")
if qc is not None:
if isinstance(qc, dict):
gw_data["quick_commands"] = qc
@@ -1196,18 +1261,26 @@ def load_gateway_config() -> GatewayConfig:
)
stt_cfg = yaml_cfg.get("stt")
+ if "stt" not in yaml_cfg and isinstance(gateway_section, dict):
+ stt_cfg = gateway_section.get("stt")
if isinstance(stt_cfg, dict):
gw_data["stt"] = stt_cfg
if "stt_echo_transcripts" in yaml_cfg:
gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"]
+ elif isinstance(gateway_section, dict) and "stt_echo_transcripts" in gateway_section:
+ gw_data["stt_echo_transcripts"] = gateway_section["stt_echo_transcripts"]
gateway_cfg = yaml_cfg.get("gateway")
if "group_sessions_per_user" in yaml_cfg:
gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"]
+ elif isinstance(gateway_section, dict) and "group_sessions_per_user" in gateway_section:
+ gw_data["group_sessions_per_user"] = gateway_section["group_sessions_per_user"]
if "thread_sessions_per_user" in yaml_cfg:
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
+ elif isinstance(gateway_section, dict) and "thread_sessions_per_user" in gateway_section:
+ gw_data["thread_sessions_per_user"] = gateway_section["thread_sessions_per_user"]
# Multiplexing flag: accept both the top-level key and the nested
# gateway.multiplex_profiles form (written by
@@ -1219,14 +1292,11 @@ def load_gateway_config() -> GatewayConfig:
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
# (matching the multiplex_profiles parity above).
_pr = yaml_cfg.get("profile_routes")
- if _pr is None:
- _gw_section = yaml_cfg.get("gateway")
- if isinstance(_gw_section, dict):
- _pr = _gw_section.get("profile_routes")
+ if _pr is None and isinstance(gateway_section, dict):
+ _pr = gateway_section.get("profile_routes")
if isinstance(_pr, list):
gw_data["profile_routes"] = _pr
- gateway_section = yaml_cfg.get("gateway")
if isinstance(gateway_section, dict):
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:
# gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true`
@@ -1242,41 +1312,49 @@ def load_gateway_config() -> GatewayConfig:
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
streaming_cfg = yaml_cfg.get("streaming")
- if not isinstance(streaming_cfg, dict):
+ if not isinstance(streaming_cfg, dict) and isinstance(gateway_section, dict):
# Fall back to nested gateway.streaming written by
# ``hermes config set gateway.streaming.*``
- streaming_cfg = (
- gateway_cfg.get("streaming")
- if isinstance(gateway_cfg, dict)
- else None
- )
+ streaming_cfg = gateway_section.get("streaming")
if isinstance(streaming_cfg, dict):
gw_data["streaming"] = streaming_cfg
if "reset_triggers" in yaml_cfg:
gw_data["reset_triggers"] = yaml_cfg["reset_triggers"]
+ elif isinstance(gateway_section, dict) and "reset_triggers" in gateway_section:
+ gw_data["reset_triggers"] = gateway_section["reset_triggers"]
if "always_log_local" in yaml_cfg:
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
+ elif isinstance(gateway_section, dict) and "always_log_local" in gateway_section:
+ gw_data["always_log_local"] = gateway_section["always_log_local"]
# write_sessions_json: top-level wins; nested gateway.* fallback
# (matches the gateway.streaming precedence pattern).
- _gw_section = yaml_cfg.get("gateway")
if "write_sessions_json" in yaml_cfg:
gw_data["write_sessions_json"] = yaml_cfg["write_sessions_json"]
- elif isinstance(_gw_section, dict) and "write_sessions_json" in _gw_section:
- gw_data["write_sessions_json"] = _gw_section["write_sessions_json"]
+ elif isinstance(gateway_section, dict) and "write_sessions_json" in gateway_section:
+ gw_data["write_sessions_json"] = gateway_section["write_sessions_json"]
if "filter_silence_narration" in yaml_cfg:
gw_data["filter_silence_narration"] = yaml_cfg[
"filter_silence_narration"
]
+ elif isinstance(gateway_section, dict) and "filter_silence_narration" in gateway_section:
+ gw_data["filter_silence_narration"] = gateway_section[
+ "filter_silence_narration"
+ ]
if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
yaml_cfg.get("unauthorized_dm_behavior"),
"pair",
)
+ elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section:
+ gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
+ gateway_section.get("unauthorized_dm_behavior"),
+ "pair",
+ )
# Merge platform config into gw_data so runtime-only settings under
# ``gateway.platforms`` are loaded the same way as top-level
diff --git a/gateway/delivery_ledger.py b/gateway/delivery_ledger.py
index 3691e961d4c4..955e1d1d3e95 100644
--- a/gateway/delivery_ledger.py
+++ b/gateway/delivery_ledger.py
@@ -200,7 +200,11 @@ def _update_state(obligation_id: str, state: str, error: str = "") -> None:
)
-def sweep_recoverable(now: Optional[float] = None) -> List[Dict[str, Any]]:
+def sweep_recoverable(
+ now: Optional[float] = None,
+ *,
+ deliverable_platforms: Optional[set] = None,
+) -> List[Dict[str, Any]]:
"""Claim undelivered rows owned by dead processes; return them for
redelivery.
@@ -209,6 +213,13 @@ def sweep_recoverable(now: Optional[float] = None) -> List[Dict[str, Any]]:
double-claim (the UPDATE is guarded on the previous owner stamp).
Rows over the attempts cap or older than the stale cutoff transition to
'abandoned' instead of being returned.
+
+ ``deliverable_platforms`` (platform value strings) restricts claiming to
+ platforms the caller can actually send on this boot. ``attempts`` is the
+ redelivery budget, so it must only be spent on a real send: a platform
+ that failed to connect would otherwise burn one attempt per boot and hit
+ the cap having never been sent once. Rows for absent platforms are left
+ untouched for a later boot; the stale cutoff still bounds them.
"""
now = now if now is not None else time.time()
pid, started = _owner_stamp()
@@ -232,6 +243,13 @@ def sweep_recoverable(now: Optional[float] = None) -> List[Dict[str, Any]]:
(now, oid),
)
continue
+ if (
+ deliverable_platforms is not None
+ and platform not in deliverable_platforms
+ ):
+ # No adapter for this platform this boot — the caller cannot
+ # send, so claiming would spend an attempt on a no-op.
+ continue
cursor = conn.execute(
"""UPDATE delivery_obligations
SET owner_pid=?, owner_started_at=?, attempts=attempts+1,
diff --git a/gateway/run.py b/gateway/run.py
index 06e20444a553..fd44e68b68ba 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -2031,6 +2031,7 @@ _CONVERSATION_SCOPED_STATE: tuple = (
"_session_model_overrides",
"_pending_one_turn_model_restores",
"_session_reasoning_overrides",
+ "_session_service_tier_overrides",
"_pending_model_notes",
"_last_resolved_model",
"_queued_events",
@@ -3055,6 +3056,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_session_model_overrides: Dict[str, Dict[str, str]] = {}
_pending_one_turn_model_restores: Dict[str, Dict[str, Any]] = {}
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
+ _session_service_tier_overrides: Dict[str, Optional[str]] = {}
_pending_turn_sidecar_notes: Dict[str, List[str]] = {}
_session_ephemeral_pin: Dict[str, tuple] = {}
_session_vc_last: Dict[str, str] = {}
@@ -3264,6 +3266,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Per-session reasoning effort overrides from /reasoning.
# Key: session_key, Value: parsed reasoning config dict.
self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
+ # Per-session fast-mode overrides from /fast.
+ # Key: session_key, Value: "priority" or None (explicit normal).
+ self._session_service_tier_overrides: Dict[str, Optional[str]] = {}
# Per-turn must-deliver notes relocated out of the ephemeral system
# prompt (auto-reset note, first-contact intro, voice-channel change).
# Staged by _handle_message_with_agent, consumed once by run_sync and
@@ -5238,6 +5243,52 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
else:
self._session_reasoning_overrides[session_key] = dict(reasoning_config)
+ def _resolve_session_service_tier(
+ self,
+ source=None,
+ session_key: Optional[str] = None,
+ ) -> Optional[str]:
+ """Resolve the effective service tier for a session.
+
+ A session-scoped /fast override wins over the config default. The
+ override dict stores "priority" or None (explicit normal), so key
+ presence — not value truthiness — decides whether it applies.
+ """
+ resolved_session_key = session_key
+ if not resolved_session_key and source is not None:
+ try:
+ resolved_session_key = self._session_key_for_source(source)
+ except Exception:
+ resolved_session_key = None
+
+ overrides = getattr(self, "_session_service_tier_overrides", {}) or {}
+ if resolved_session_key and resolved_session_key in overrides:
+ return overrides[resolved_session_key]
+ return self._load_service_tier()
+
+ def _set_session_service_tier_override(
+ self,
+ session_key: str,
+ service_tier,
+ clear: bool = False,
+ ) -> None:
+ """Set or clear the session-scoped /fast override.
+
+ ``service_tier`` is "priority" or None (explicit normal). Pass
+ ``clear=True`` to remove the override entirely (fall back to config).
+ """
+ if not session_key:
+ return
+ if "_session_service_tier_overrides" not in self.__dict__:
+ # Force an instance-level dict: the class attribute is a shared
+ # default for partially-constructed test runners, and mutating it
+ # would leak overrides across runner instances.
+ self._session_service_tier_overrides = {}
+ if clear:
+ self._session_service_tier_overrides.pop(session_key, None)
+ else:
+ self._session_service_tier_overrides[session_key] = service_tier
+
@staticmethod
def _load_service_tier() -> str | None:
"""Load Priority Processing setting from config.yaml.
@@ -6980,7 +7031,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if not ledger_enabled():
return 0
- claimed = await asyncio.to_thread(sweep_recoverable)
+ # Only claim rows we can actually send this boot: self.adapters
+ # holds a platform only after its connect() succeeded, and each
+ # claim spends one of the row's three redelivery attempts.
+ _deliverable = {
+ getattr(p, "value", str(p)) for p in self.adapters
+ }
+ claimed = await asyncio.to_thread(
+ sweep_recoverable, None, deliverable_platforms=_deliverable
+ )
except Exception:
logger.debug("delivery ledger sweep failed", exc_info=True)
return 0
@@ -7858,13 +7917,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
await asyncio.sleep(1.0)
# Notify the chat that initiated /restart that the gateway is back.
+ chat_restart_notification_pending = _restart_notification_pending()
planned_restart_notification_pending = _planned_restart_notification_pending()
# Capture, before _send_restart_notification() unlinks the marker,
# whether this process booted from a chat-originated /restart. Used as
# a one-shot signal by the /restart redelivery guard so a missing
# dedup marker only suppresses a /restart when we KNOW we just came out
# of a restart cycle (see _is_stale_restart_redelivery).
- if _restart_notification_pending() or planned_restart_notification_pending:
+ if chat_restart_notification_pending:
self._booted_from_restart = True
await self._send_restart_notification()
@@ -13805,8 +13865,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
The previous gateway wrote ``.restart_last_processed.json`` with the
triggering platform + update_id when it processed the /restart. If
we now see a /restart on the same platform with an update_id <= that
- recorded value AND the marker is recent (< 5 minutes), it's a
- redelivery and should be ignored.
+ recorded value, it is a redelivery when this process booted from that
+ restart. Otherwise the marker must still be recent (< 5 minutes).
Only applies to Telegram today (the only platform that exposes a
numeric cross-session update ordering); other platforms return False.
@@ -13858,6 +13918,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
recorded_uid = data.get("update_id")
if not isinstance(recorded_uid, int):
return False
+ if event.platform_update_id > recorded_uid:
+ return False
+
+ # A service-managed restart can legitimately take longer than the
+ # marker's normal five-minute trust window while adapters, cron, and
+ # in-flight deliveries drain. If this process booted from the recorded
+ # chat restart, the first same-or-older update is still that restart's
+ # redelivery regardless of elapsed wall time. Consume the boot signal
+ # one-shot so a later genuine command is evaluated normally.
+ if getattr(self, "_booted_from_restart", False):
+ self._booted_from_restart = False
+ return True
+
# Staleness guard: ignore markers older than 5 minutes. A legitimately
# old marker (e.g. crash recovery where notify never fired) should not
# swallow a fresh /restart from the user.
@@ -13865,7 +13938,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if isinstance(requested_at, (int, float)):
if time.time() - requested_at > 300:
return False
- return event.platform_update_id <= recorded_uid
+ return True
@@ -14678,7 +14751,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
source=source, model=model
)
self._reasoning_config = reasoning_config
- self._service_tier = self._load_service_tier()
+ self._service_tier = self._resolve_session_service_tier(source=source)
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
# Enrich the prompt with image descriptions so the background
@@ -19893,7 +19966,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
model=model,
)
self._reasoning_config = reasoning_config
- self._service_tier = self._load_service_tier()
+ self._service_tier = self._resolve_session_service_tier(
+ source=source, session_key=session_key
+ )
# Set up stream consumer for token streaming or interim commentary.
_stream_consumer = None
_stream_delta_cb = None
@@ -21742,14 +21817,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
pass
except Exception as e:
logger.debug("Stream consumer wait before queued message failed: %s", e)
- _previewed = bool(result.get("response_previewed"))
- first_response = result.get("final_response", "")
+ # The queued branch needs raw ``result`` for interruption,
+ # history, and recursion state, but delivery must use the
+ # finalized task result. The latter contains empty/failure
+ # normalization and any final response processing applied by
+ # _run_agent_task; sending the raw copy bypasses those steps.
+ _delivery_result = response if isinstance(response, dict) else (result or {})
+ _previewed = bool(_delivery_result.get("response_previewed"))
+ first_response = _delivery_result.get("final_response", "")
_already_streamed = _stream_confirmed_final_delivery(
_sc,
first_response,
previewed=_previewed,
)
- if first_response and not _already_streamed:
+ # Apply the same predicate as the normal completed-turn path.
+ # This direct queued-send branch predates intentional-silence
+ # filtering, so without this check it leaks the literal marker.
+ try:
+ from gateway.response_filters import is_intentional_silence_agent_result
+ _intentional_silence = is_intentional_silence_agent_result(
+ _delivery_result, first_response,
+ )
+ except Exception:
+ _intentional_silence = False
+ if _intentional_silence:
+ logger.info(
+ "Queued follow-up for session %s: suppressing intentional silence marker before continuing.",
+ session_key or "?",
+ )
+ elif first_response and not _already_streamed:
try:
logger.info(
"Queued follow-up for session %s: final stream delivery not confirmed; sending first response before continuing.",
diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py
index f4c89afd0cf2..c28a32214678 100644
--- a/gateway/slash_commands.py
+++ b/gateway/slash_commands.py
@@ -1436,10 +1436,10 @@ class GatewaySlashCommandsMixin:
Supports:
/model — interactive picker (Telegram/Discord) or text list
- /model — switch model (persists by default)
+ /model — switch model (this session only)
/model --once — switch for the next turn only
- /model --session — switch for this session only
- /model --global — switch and persist (explicit)
+ /model --session — switch for this session only (explicit)
+ /model --global — switch and persist to config.yaml
/model --provider — switch provider + model
/model --provider — switch to provider, auto-detect model
"""
@@ -1477,6 +1477,7 @@ class GatewaySlashCommandsMixin:
is_global_flag,
is_session,
is_once=one_turn,
+ explicit_provider=explicit_provider,
)
# --refresh: bust the disk cache so the picker shows live data.
@@ -1494,6 +1495,7 @@ class GatewaySlashCommandsMixin:
current_api_key = ""
user_provs = None
custom_provs = None
+ excluded_provs = []
config_path = (_command_profile_home or _hermes_home) / "config.yaml"
try:
cfg = _load_gateway_config()
@@ -1509,6 +1511,9 @@ class GatewaySlashCommandsMixin:
custom_provs = get_compatible_custom_providers(cfg)
except Exception:
custom_provs = cfg.get("custom_providers")
+ _excl = cfg.get("model_catalog", {}).get("excluded_providers")
+ if isinstance(_excl, list):
+ excluded_provs = _excl
except Exception:
pass
@@ -1552,6 +1557,7 @@ class GatewaySlashCommandsMixin:
custom_providers=custom_provs,
max_models=50,
include_moa=True,
+ excluded_providers=excluded_provs,
)
except Exception:
providers = []
@@ -1662,11 +1668,17 @@ class GatewaySlashCommandsMixin:
"Failed to persist model switch to DB: %s", exc
)
- # Store model note + session override
+ # Store model note + session override. Use display
+ # form (strips opaque Palantir prefix) for the user-
+ # visible note; session-override map still gets the
+ # full opaque ID, which is what the wire needs.
+ from hermes_cli.model_switch import format_model_for_display
+ _display_cur = format_model_for_display(_cur_model)
+ _display_new = format_model_for_display(result.new_model)
if not hasattr(_self, "_pending_model_notes"):
_self._pending_model_notes = {}
_self._pending_model_notes[_session_key] = (
- f"[Note: model was just switched from {_cur_model} to {result.new_model} "
+ f"[Note: model was just switched from {_display_cur} to {_display_new} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
@@ -1742,9 +1754,11 @@ class GatewaySlashCommandsMixin:
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
- # Build confirmation text
+ # Build confirmation text. Use display form so opaque
+ # Palantir IDs (ri.language-model-service..*) get
+ # shortened to their trailing slug for the UI.
plabel = result.provider_label or result.target_provider
- lines = [t("gateway.model.switched", model=result.new_model)]
+ lines = [t("gateway.model.switched", model=format_model_for_display(result.new_model))]
lines.append(t("gateway.model.provider_label", provider=plabel))
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
@@ -1823,6 +1837,7 @@ class GatewaySlashCommandsMixin:
user_providers=user_provs,
custom_providers=custom_provs,
max_models=5,
+ excluded_providers=excluded_provs,
)
for p in providers:
tag = t("gateway.model.current_tag") if p["is_current"] else ""
@@ -1938,10 +1953,13 @@ class GatewaySlashCommandsMixin:
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
+ # Display form strips opaque Palantir RID prefixes; the override
+ # map below keeps the full ID for the wire.
+ from hermes_cli.model_switch import format_model_for_display
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
- f"[Note: model was just switched from {current_model} to {result.new_model} "
+ f"[Note: model was just switched from {format_model_for_display(current_model)} to {format_model_for_display(result.new_model)} "
f"via {result.provider_label or result.target_provider}. "
f"{'This override applies to the next turn only. ' if one_turn else ''}"
f"Adjust your self-identification accordingly.]"
@@ -2037,7 +2055,7 @@ class GatewaySlashCommandsMixin:
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
- lines = [t("gateway.model.switched", model=result.new_model)]
+ lines = [t("gateway.model.switched", model=format_model_for_display(result.new_model))]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
@@ -3089,43 +3107,64 @@ class GatewaySlashCommandsMixin:
return out
async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
- """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
+ """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.
+
+ Session-scoped by default; ``--global`` persists agent.service_tier
+ to config.yaml (parity with /model and /reasoning).
+ """
from gateway.run import _load_gateway_config, _resolve_gateway_model
from hermes_cli.models import model_supports_fast_mode
- args = event.get_command_args().strip().lower()
- self._service_tier = self._load_service_tier()
+ raw_args = event.get_command_args().strip().lower()
+ # Reuse the /reasoning arg parser: strips --global (any position),
+ # normalizes unicode dashes.
+ args, persist_global = self._parse_reasoning_command_args(raw_args)
+ session_key = self._session_key_for_source(event.source)
+ self._service_tier = self._resolve_session_service_tier(
+ session_key=session_key
+ )
user_config = _load_gateway_config()
model = _resolve_gateway_model(user_config)
if not model_supports_fast_mode(model):
return t("gateway.fast.not_supported")
- def _apply_fast_selection(value: str) -> str:
+ def _apply_fast_selection(value: str, persist: bool = False) -> str:
"""Apply a /fast argument (typed or picked) and return the reply."""
if value in {"fast", "on"}:
- self._service_tier = "priority"
+ tier = "priority"
saved_value = "fast"
label = t("gateway.fast.label_fast")
elif value in {"normal", "off"}:
- self._service_tier = None
+ tier = None
saved_value = "normal"
label = t("gateway.fast.label_normal")
else:
return t("gateway.fast.unknown_arg", arg=value)
- if self._save_gateway_config_key("agent.service_tier", saved_value):
- return t("gateway.fast.saved", label=label)
+ self._service_tier = tier
+ if persist:
+ if self._save_gateway_config_key("agent.service_tier", saved_value):
+ # Global write supersedes any session override.
+ self._set_session_service_tier_override(
+ session_key, None, clear=True
+ )
+ self._evict_cached_agent(session_key)
+ return t("gateway.fast.saved", label=label)
+ # Config write failed — fall back to a session override so the
+ # user's choice still applies (mirrors /reasoning --global).
+ self._set_session_service_tier_override(session_key, tier)
+ self._evict_cached_agent(session_key)
+ return t("gateway.fast.session_only", label=label)
+ self._set_session_service_tier_override(session_key, tier)
+ self._evict_cached_agent(session_key)
return t("gateway.fast.session_only", label=label)
if not args or args == "status":
is_fast = self._service_tier == "priority"
status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal")
- # Interactive picker on platforms that support it.
- session_key = self._session_key_for_source(event.source)
-
async def _on_fast_choice(_chat_id: str, value: str) -> str:
- return _apply_fast_selection(value)
+ return _apply_fast_selection(value, persist=persist_global)
picker_sent = await self._try_send_choice_picker(
event,
@@ -3150,7 +3189,7 @@ class GatewaySlashCommandsMixin:
return t("gateway.fast.status", mode=status)
- return _apply_fast_selection(args)
+ return _apply_fast_selection(args, persist=persist_global)
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py
index f1aaa393829c..0e9a623a7174 100644
--- a/gateway/stream_consumer.py
+++ b/gateway/stream_consumer.py
@@ -189,6 +189,13 @@ class GatewayStreamConsumer:
# subsequently failed.
self._final_content_delivered = False
self._delivered_commentary_texts: list[str] = []
+ # Retains the finalized visible text of each streaming segment so
+ # ``has_delivered_text`` can still match after ``_reset_segment_state``
+ # clears ``_last_sent_text``. Without this, a segment break (triggered
+ # by ``on_segment_break`` or ``on_commentary``) erases the only record
+ # of what was delivered, and the gateway's final-send suppression
+ # can't recognize an already-delivered response. (#65919 review)
+ self._delivered_segment_texts: list[str] = []
# Cache adapter lifecycle capability: only platforms that need an
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
# a redundant final edit. Everyone else keeps the fast path.
@@ -320,7 +327,10 @@ class GatewayStreamConsumer:
visible_prefix = self._visible_prefix().strip()
if visible_prefix == target:
return True
- return any(sent.strip() == target for sent in self._delivered_commentary_texts)
+ return any(
+ sent.strip() == target
+ for sent in (*self._delivered_commentary_texts, *self._delivered_segment_texts)
+ )
def on_segment_break(self) -> None:
"""Finalize the current stream segment and start a fresh message."""
@@ -344,6 +354,13 @@ class GatewayStreamConsumer:
def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None:
if preserve_no_edit and self._message_id == "__no_edit__":
return
+ # Retain the finalized visible text of the current segment before
+ # clearing ``_last_sent_text``, so ``has_delivered_text`` can still
+ # match it after a segment break. (#65919 review)
+ if self._last_sent_text:
+ finalized = self._clean_for_display(self._last_sent_text).strip()
+ if finalized:
+ self._delivered_segment_texts.append(finalized)
self._message_id = None
self._message_created_ts = None
self._accumulated = ""
diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py
index 8f9e6b8e886e..e10824384001 100644
--- a/hermes_cli/cli_commands_mixin.py
+++ b/hermes_cli/cli_commands_mixin.py
@@ -2515,13 +2515,14 @@ class CLICommandsMixin:
Usage:
/reasoning Show current effort level and display state
- /reasoning Set effort (none, minimal, low, medium, high, xhigh, max, ultra)
+ /reasoning Set effort for this session only (none, minimal, low, medium, high, xhigh, max, ultra)
+ /reasoning --global Persist reasoning effort to config.yaml
/reasoning show|on Show model thinking/reasoning in output
/reasoning hide|off Hide model thinking/reasoning from output
/reasoning full Show complete thinking (no 10-line clamp)
/reasoning clamp Collapse long thinking to the first 10 lines
"""
- from cli import _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value
+ from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value
parts = cmd.strip().split(maxsplit=1)
if len(parts) < 2:
@@ -2537,10 +2538,20 @@ class CLICommandsMixin:
full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines"
_cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}")
_cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}")
- _cprint(f" {_DIM}Usage: /reasoning {_RST}")
+ _cprint(f" {_DIM}Usage: /reasoning [--global]{_RST}")
return
arg = parts[1].strip().lower()
+ arg_tokens = arg.split()
+ # Session scope is the default; --global opts into persisting to
+ # config.yaml. --session is accepted as an explicit no-op for parity
+ # with /model and the gateway /reasoning handler.
+ explicit_global = "--global" in arg_tokens
+ if explicit_global or "--session" in arg_tokens:
+ arg = " ".join(
+ token for token in arg_tokens
+ if token not in ("--global", "--session")
+ )
# Display toggle
if arg in {"show", "on"}:
@@ -2580,15 +2591,23 @@ class CLICommandsMixin:
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
_cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}")
_cprint(f" {_DIM}Display: show, hide{_RST}")
+ _cprint(f" {_DIM}Scope: session-scoped by default, --global to persist{_RST}")
return
self.reasoning_config = parsed
self.agent = None # Force agent re-init with new reasoning config
- if save_config_value("agent.reasoning_effort", arg):
+ if explicit_global and save_config_value("agent.reasoning_effort", arg):
+ agent_cfg = CLI_CONFIG.get("agent")
+ if not isinstance(agent_cfg, dict):
+ agent_cfg = {}
+ CLI_CONFIG["agent"] = agent_cfg
+ agent_cfg["reasoning_effort"] = arg
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
+ elif explicit_global:
+ _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only; config save failed){_RST}")
else:
- _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}")
+ _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (this session — use --global to persist){_RST}")
def _handle_busy_command(self, cmd: str):
"""Handle /busy — control what Enter does while Hermes is working.
@@ -2634,7 +2653,11 @@ class CLICommandsMixin:
_cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}")
def _handle_fast_command(self, cmd: str):
- """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode)."""
+ """Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).
+
+ Session-scoped by default; ``--global`` persists agent.service_tier
+ to config.yaml (parity with /model and /reasoning).
+ """
from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value
if not self._fast_command_available():
_cprint(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).")
@@ -2653,10 +2676,15 @@ class CLICommandsMixin:
if len(parts) < 2 or parts[1].strip().lower() == "status":
status = "fast" if self.service_tier == "priority" else "normal"
_cprint(f" {_ACCENT}{feature_name}: {status}{_RST}")
- _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}")
+ _cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}")
return
- arg = parts[1].strip().lower()
+ arg_tokens = parts[1].strip().lower().split()
+ explicit_global = "--global" in arg_tokens
+ arg = " ".join(
+ token for token in arg_tokens
+ if token not in ("--global", "--session")
+ )
if arg in {"fast", "on"}:
self.service_tier = "priority"
@@ -2668,14 +2696,16 @@ class CLICommandsMixin:
label = "NORMAL"
else:
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
- _cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}")
+ _cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}")
return
self.agent = None # Force agent re-init with new service-tier config
- if save_config_value("agent.service_tier", saved_value):
+ if explicit_global and save_config_value("agent.service_tier", saved_value):
_cprint(f" {_ACCENT}✓ {feature_name} set to {label} (saved to config){_RST}")
+ elif explicit_global:
+ _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only; config save failed){_RST}")
else:
- _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only){_RST}")
+ _cprint(f" {_ACCENT}✓ {feature_name} set to {label} (this session — use --global to persist){_RST}")
def _handle_debug_command(self, cmd_original: str = ""):
"""Handle /debug — upload debug report + logs and print share URLs.
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index 8fb6926d8671..76642b7b29bb 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -131,7 +131,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
- CommandDef("model", "Switch model (persists by default)", "Configuration",
+ CommandDef("model", "Switch model (session-scoped; --global to persist)", "Configuration",
args_hint="[model] [--provider name] [--global|--session] [--refresh]"),
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
"Configuration", aliases=("codex_runtime",),
@@ -153,11 +153,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
"Configuration"),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
- args_hint="[level|show|hide|full|clamp]",
- subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp")),
+ args_hint="[level|show|hide|full|clamp] [--global]",
+ subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")),
CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration",
- args_hint="[normal|fast|status]",
- subcommands=("normal", "fast", "status", "on", "off")),
+ args_hint="[normal|fast|status] [--global]",
+ subcommands=("normal", "fast", "status", "on", "off", "--global")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 3cd431260c0e..947fddabf415 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -1409,6 +1409,20 @@ DEFAULT_CONFIG = {
# small so a slow/dead server adds little to first-response latency.
"mcp_discovery_timeout": 1.5,
+ # MCP runtime behavior (distinct from the per-server definitions in
+ # mcp_servers: and from the auxiliary.mcp side-LLM task settings).
+ "mcp": {
+ # Auto-reload MCP connections when config.yaml's mcp_servers section
+ # changes at runtime (CLI file watcher, default on).
+ # Set to false to stop the automatic reload: every automatic reload
+ # rebuilds the agent tool surface and INVALIDATES the provider
+ # prompt cache (the next message re-sends the full input prefix),
+ # which is expensive on long-context / high-reasoning models.
+ # When disabled, the watcher still detects the change and prints
+ # guidance to apply it deliberately via /reload-mcp.
+ "auto_reload_on_config_change": True,
+ },
+
# Tool-output truncation thresholds. When terminal output or a
# single read_file page exceeds these limits, Hermes truncates the
# payload sent to the model (keeping head + tail for terminal,
@@ -1934,7 +1948,7 @@ DEFAULT_CONFIG = {
"first_lines": 2,
"last_lines": 2,
},
- "interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
+ "interim_assistant_messages": True, # Gateway: send natural mid-turn assistant status messages. Desktop: keep mid-turn narration between tool calls instead of collapsing to the final message.
# Codex Responses models narrate progress in a dedicated commentary
# channel. When true (default), completed commentary messages are
# delivered as visible mid-turn updates via the interim message path.
@@ -2167,7 +2181,9 @@ DEFAULT_CONFIG = {
"openai": {
"model": "gpt-4o-mini-tts",
"voice": "alloy",
- # Voices: alloy, echo, fable, onyx, nova, shimmer
+ # Voices: alloy, ash, ballad, cedar, coral, echo, fable, marin,
+ # nova, onyx, sage, shimmer, verse (gpt-4o-mini-tts; the tts-1
+ # era stopped at alloy/echo/fable/onyx/nova/shimmer)
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
@@ -2195,6 +2211,14 @@ DEFAULT_CONFIG = {
"model": "voxtral-mini-tts-2603",
"voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral
},
+ "minimax": {
+ "model": "speech-02-hd",
+ "voice_id": "English_expressive_narrator",
+ },
+ "kittentts": {
+ "model": "KittenML/kitten-tts-nano-0.8-int8", # nano 25MB; micro 41MB; mini 80MB
+ "voice": "Jasper",
+ },
"neutts": {
"ref_audio": "", # Path to reference voice audio (empty = bundled default)
"ref_text": "", # Path to reference voice transcript (empty = bundled default)
@@ -3299,10 +3323,13 @@ DEFAULT_CONFIG = {
# OAuth or XAI_API_KEY) AND the x_search toolset is enabled in
# `hermes tools`. These settings tune the backing Responses API call.
"x_search": {
- # xAI model used for the Responses call. grok-4.20-reasoning is
- # the recommended default; any Grok model with x_search tool
+ # xAI model used for the Responses call. grok-4.5 is the
+ # recommended default; any Grok model with x_search tool
# access works.
- "model": "grok-4.20-reasoning",
+ "model": "grok-4.5",
+ # Optional reasoning effort sent to xAI Responses API models that
+ # support it. Leave null to preserve the selected model's default.
+ "reasoning_effort": None,
# Request timeout in seconds (minimum 30). x_search can take
# 60-120s for complex queries — the default is generous.
"timeout_seconds": 180,
@@ -5512,7 +5539,14 @@ _EXTRA_KNOWN_ROOT_KEYS = {
"plugins", # plugin enable/disable lists (hermes_cli/plugins_cmd.py)
"smart_model_routing", # written by the setup wizard (hermes_cli/setup.py)
"platform_toolsets", # written by the setup wizard (hermes_cli/setup.py)
+ "known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow
"session_reset", # top-level form read by gateway/config.py + setup
+ "group_sessions_per_user", # top-level form bridged by gateway/config.py
+ "thread_sessions_per_user", # top-level form bridged by gateway/config.py
+ "stt_echo_transcripts", # top-level form bridged by gateway/config.py
+ "reset_triggers", # top-level form bridged by gateway/config.py
+ "always_log_local", # top-level form bridged by gateway/config.py
+ "filter_silence_narration", # top-level form bridged by gateway/config.py
"multiplex_profiles", # top-level form accepted alongside gateway.multiplex_profiles
"profile_routes", # top-level form accepted alongside gateway.profile_routes
"platforms", # top-level per-platform map merged by gateway/config.py
@@ -5675,28 +5709,21 @@ def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["
" base_url: https://...",
))
- # ── Unknown / misplaced root-level keys ──────────────────────────────
- # Typos like skillz:/secrity: were previously silent (only provider-like
- # fields were flagged). Warn on any unknown top-level key so config
- # hygiene surfaces without breaking startup.
+ # ── Root-level keys that look misplaced ──────────────────────────────
+ # Only provider-like fields (base_url, api_key, …) are flagged. Arbitrary
+ # unknown top-level keys are deliberately NOT warned about: top-level
+ # scalars are bridged into os.environ (gateway/run.py, hermes send) so
+ # users can feed skills and external apps env-style keys from config.yaml
+ # — a closed-world allowlist can never enumerate those.
for key in config:
if key.startswith("_"):
continue
- if key in _KNOWN_ROOT_KEYS:
- continue
- if key in _CUSTOM_PROVIDER_LIKE_FIELDS:
+ if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
issues.append(ConfigIssue(
"warning",
f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
f"Move '{key}' under the appropriate section",
))
- else:
- issues.append(ConfigIssue(
- "warning",
- f"Unknown top-level config key '{key}' — it will be ignored",
- "Check for typos, or remove the key if it is not a supported config root. "
- "Run 'hermes doctor' for more detail.",
- ))
return issues
@@ -6946,6 +6973,31 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
return config
+def is_provider_enabled(provider_cfg: Optional[Dict[str, Any]]) -> bool:
+ """Return whether a ``providers.`` config block is enabled.
+
+ A provider is enabled by default. Only an explicit ``enabled: false`` in
+ the block hides it from the model picker, ``/models`` listings, the
+ runtime resolver and the doctor / status output.
+
+ Backward-compat: configs without the ``enabled`` key keep working as
+ before — the default is ``True``.
+
+ Pass any non-dict (None, list, string) and you get ``True`` too, so
+ malformed entries don't disappear silently; they'll still be flagged
+ by the existing validation paths.
+ """
+ if not isinstance(provider_cfg, dict):
+ return True
+ flag = provider_cfg.get("enabled", True)
+ if isinstance(flag, bool):
+ return flag
+ # YAML can produce strings for "true"/"false" depending on quoting.
+ if isinstance(flag, str):
+ return flag.strip().lower() not in {"false", "0", "no", "off"}
+ return bool(flag)
+
+
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
"""Traverse nested dict keys safely, returning ``default`` on any miss.
@@ -8492,8 +8544,190 @@ def _default_value_for_key(dotted_key: str):
return node if not isinstance(node, dict) else None
-def set_config_value(key: str, value: str):
- """Set a configuration value."""
+# Known top-level config keys that intentionally accept arbitrary user-supplied
+# child keys ("dictionary-shaped" config: the schema declares the dict but the
+# user populates its keys). Schema validation accepts ANY path below these
+# without deep checking, so users can set e.g. ``mcp_servers.my-server.command``
+# or ``providers.openrouter.api_key`` without us needing to know server names.
+_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
+ "providers",
+ "credential_pool_strategies",
+ "mcp_servers",
+ "hooks",
+ "quick_commands",
+ "personalities",
+ "command_allowlist",
+ "model_catalog",
+ "channel_prompts",
+ "server_actions",
+ "secrets",
+ "goals",
+})
+
+# Top-level keys whose sub-keys are partially schema-defined (e.g. on a
+# PlatformConfig dataclass) but where users may legitimately add fields
+# that DEFAULT_CONFIG doesn't enumerate (extras, per-channel overrides,
+# etc.). For these we validate the FIRST segment but accept anything below.
+_SCHEMA_DEFINED_DICT_KEYS = frozenset({
+ # Platform configs — PlatformConfig dataclass + dynamic extras
+ "discord", "telegram", "slack", "whatsapp", "signal", "mattermost",
+ "matrix", "feishu", "wecom", "weixin", "bluebubbles", "qqbot", "yuanbao",
+ "email", "sms", "dingtalk",
+ # MCP server template / dynamic auth dicts
+ "sessions", "checkpoints",
+})
+
+# Top-level keys that can be ANY user-supplied name (platform/provider dict
+# shapes where the outer key IS user-defined).
+_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
+ "custom_providers", # list-shaped, but indexed by position
+})
+
+# Container keys whose immediate child IS a user-supplied platform name
+# (``platforms..``). These appear both at the top level and
+# nested under ``gateway`` — current docs configure platforms under
+# ``gateway.platforms.`` (website/docs/developer-guide/
+# adding-platform-adapters.md) and ``gateway/config.py`` also resolves a
+# top-level ``platforms`` map. Anything below the platform-name segment is
+# accepted because ``PlatformConfig`` carries an open ``extra`` mapping.
+_PLATFORM_CONTAINER_KEYS = frozenset({"platforms"})
+
+
+def _known_top_level_keys() -> set[str]:
+ """Return the union of known top-level config keys for validation.
+
+ Combines :data:`DEFAULT_CONFIG` with the dynamic categories that
+ accept user-supplied child keys. Used by :func:`_validate_config_key`
+ to decide whether a ``hermes config set`` invocation is targeting a
+ known shape.
+ """
+ keys = set(DEFAULT_CONFIG.keys())
+ keys.update(_OPEN_DICT_TOP_LEVEL_KEYS)
+ keys.update(_DYNAMIC_TOP_LEVEL_KEYS)
+ keys.update(_SCHEMA_DEFINED_DICT_KEYS)
+ return keys
+
+
+def _suggest_closest_key(key: str, candidates: set[str], cutoff: float = 0.6) -> Optional[str]:
+ """Return the closest valid key name from ``candidates`` if any are
+ similar enough to ``key``, else None. Used by ``hermes config set``
+ to point users at the right path when they've typo'd a top-level key.
+
+ Uses :func:`difflib.get_close_matches` with a conservative cutoff so
+ we only suggest when there's a strong match — we'd rather say nothing
+ than mislead a user toward a wrong-but-similar key.
+ """
+ import difflib
+ matches = difflib.get_close_matches(key, sorted(candidates), n=1, cutoff=cutoff)
+ return matches[0] if matches else None
+
+
+def _validate_config_key(key: str) -> tuple[bool, Optional[str]]:
+ """Validate a dotted config-key path against the known schema.
+
+ Returns ``(is_known, suggested_alternative_or_None)``. Known keys
+ return ``(True, None)``. Unknown keys return ``(False, )``
+ where ```` may be ``None`` if no close match was found.
+
+ Validates as deep as DEFAULT_CONFIG can be safely walked, then stops
+ at any segment that hits an open-dict container (mcp_servers,
+ providers, hooks, etc.) where users define the inner keys themselves.
+
+ Headline case from #34067: ``gateway.discord.gateway_restart_notification``
+ was silently written, even though ``gateway`` only has 4 known sub-keys
+ (``strict``, ``media_delivery_allow_dirs``, ``trust_recent_files``,
+ ``trust_recent_files_seconds``). The correct path is
+ ``discord.gateway_restart_notification`` (platform configs live at the
+ top level, not under a ``platforms`` namespace).
+ """
+ if not key:
+ return False, None
+
+ segments = key.split(".")
+ top = segments[0]
+
+ # ── Underscore-prefixed keys are internal/test markers ───────────
+ # A leading underscore on the top-level segment (e.g. ``_test.shim_marker``)
+ # signals an intentionally non-schema, internal key. Test harnesses and
+ # tooling use these to write a deterministic marker into config.yaml
+ # without polluting the user-facing schema (see the Docker privilege-drop
+ # shim test, which writes ``_test.shim_marker`` to probe file ownership).
+ # Python's own convention treats a leading underscore as "private"; we
+ # honour that here so schema validation never blocks deliberately-internal
+ # keys. This is narrow: only the FIRST segment is checked, so a real typo
+ # like ``agent._max_turns`` still gets caught at the sub-key level.
+ if top.startswith("_"):
+ return True, None
+
+ known = _known_top_level_keys()
+
+ # ── First-segment validation ─────────────────────────────────────
+ # Top-level ``platforms..`` is a valid current shape:
+ # ``gateway/config.py`` resolves a top-level ``platforms`` map in
+ # addition to ``gateway.platforms``. Accept anything below it.
+ if top in _PLATFORM_CONTAINER_KEYS:
+ return True, None
+
+ if top not in known:
+ suggestion = _suggest_closest_key(top, known)
+ if suggestion is not None:
+ rest = ".".join(segments[1:])
+ suggested_full = f"{suggestion}.{rest}" if rest else suggestion
+ return False, suggested_full
+
+ return False, None
+
+ # ── Deeper validation ────────────────────────────────────────────
+ # Walk DEFAULT_CONFIG along the user's segments. Stop at:
+ # - An open-dict container (user-defined inner keys are OK below it)
+ # - A schema-defined-but-extensible dict (accept anything below)
+ # - A leaf scalar (the user's key is fully consumed and valid)
+ # - An unknown sub-key (return False with a same-level suggestion)
+ if top in _OPEN_DICT_TOP_LEVEL_KEYS or top in _DYNAMIC_TOP_LEVEL_KEYS or top in _SCHEMA_DEFINED_DICT_KEYS:
+ # Any path below these is accepted — the user defines the inner
+ # shape themselves (mcp_servers..command, discord.,
+ # providers..api_key, etc.).
+ return True, None
+
+ node: Any = DEFAULT_CONFIG.get(top)
+ consumed = [top]
+ for seg in segments[1:]:
+ # ``gateway.platforms..`` (and any other nested
+ # ``platforms`` container) — the segment after ``platforms`` is a
+ # user-supplied platform name, so accept everything below it.
+ if seg in _PLATFORM_CONTAINER_KEYS:
+ return True, None
+ if not isinstance(node, dict):
+ # We hit a scalar leaf before consuming the user's full path —
+ # they're trying to set ``foo.bar`` where ``foo`` is a string.
+ # Accept it (set_config_value's coercion will replace the
+ # leaf with a dict, matching pre-existing behavior).
+ return True, None
+ if seg not in node:
+ # Suggest the closest sibling at this depth.
+ sibling_suggestion = _suggest_closest_key(seg, set(node.keys()))
+ if sibling_suggestion is not None:
+ fixed_path = ".".join(consumed + [sibling_suggestion])
+ return False, fixed_path
+ return False, None
+ consumed.append(seg)
+ node = node[seg]
+
+ # Walked the entire user-supplied path without hitting an unknown
+ # segment — it's known.
+ return True, None
+
+
+def set_config_value(key: str, value: str, force: bool = False):
+ """Set a configuration value.
+
+ Args:
+ key: Dotted config path (e.g. ``terminal.backend``).
+ value: String value (auto-coerced to bool/int/float when matching).
+ force: When True, skip the unknown-key warning — useful for scripted
+ writes of keys the running version doesn't recognize yet. The CLI
+ exposes this via ``hermes config set --force``.
+ """
if is_managed():
managed_error("set configuration values")
return
@@ -8522,7 +8756,16 @@ def set_config_value(key: str, value: str):
save_provider_env_credential(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}")
return
-
+
+ # Unknown-key notice (#34067): the key is still written (arbitrary keys
+ # are supported — top-level scalars are bridged into os.environ for
+ # skills and external apps), but a plausible-but-wrong dotted path like
+ # ``gateway.discord.gateway_restart_notification`` previously reported
+ # bare success and left the user debugging behavior that never changed.
+ # Warn after the write so the user gets immediate feedback plus a
+ # "did you mean" hint, without blocking legitimate unknown keys.
+ is_known, suggestion = _validate_config_key(key)
+
# Otherwise it goes to config.yaml
# Read the raw user config (not merged with defaults) to avoid
# dumping all default values back to the file
@@ -8589,6 +8832,23 @@ def set_config_value(key: str, value: str):
_display_value = value
print(f"✓ Set {key} = {_display_value} in {config_path}")
+ # Post-write unknown-key notice (#34067): value IS saved, but tell the
+ # user the runtime may never read it and suggest the likely-intended path.
+ if not is_known and not force:
+ print(color(
+ f"⚠ '{key}' is not a recognized config key — it was saved anyway, "
+ "but Hermes may not read it.",
+ Colors.YELLOW,
+ ))
+ if suggestion:
+ print(color(f" Did you mean: {suggestion}", Colors.YELLOW))
+ print(color(
+ " (Custom top-level keys are supported and bridged to the "
+ "environment for skills/external tools. Use --force to skip "
+ "this notice.)",
+ Colors.DIM,
+ ))
+
def get_config_value(key: str, *, as_json: bool = False):
"""Print a resolved configuration value."""
@@ -8692,15 +8952,18 @@ def config_command(args):
elif subcmd == "set":
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
+ force = bool(getattr(args, 'force', False))
if not key or value is None:
- print("Usage: hermes config set ")
+ print("Usage: hermes config set [--force] ")
print()
print("Examples:")
print(" hermes config set model anthropic/claude-sonnet-4")
print(" hermes config set terminal.backend docker")
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
+ print()
+ print(" --force: skip the unknown-key notice for unrecognized keys")
sys.exit(1)
- set_config_value(key, value)
+ set_config_value(key, value, force=force)
elif subcmd == "unset":
key = getattr(args, 'key', None)
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index 490683f38d7f..68338bfe89e4 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -869,7 +869,12 @@ def run_doctor(args):
user_providers = cfg.get("providers")
if isinstance(user_providers, dict):
- known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())
+ from hermes_cli.config import is_provider_enabled
+ known_providers.update(
+ str(name).strip().lower()
+ for name, prov_cfg in user_providers.items()
+ if str(name).strip() and is_provider_enabled(prov_cfg)
+ )
for entry in custom_providers:
if not isinstance(entry, dict):
continue
diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py
index 21e055402293..be99bbdc9e83 100644
--- a/hermes_cli/goals.py
+++ b/hermes_cli/goals.py
@@ -66,6 +66,11 @@ _JUDGE_RESPONSE_SNIPPET_CHARS = 4000
# exhausted with every reply shaped like `judge returned empty response` or
# `judge reply was not JSON`.
DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3
+# Transport failures (API auth errors 401, timeouts, DNS, etc.) are also
+# tracked and auto-pause the loop after this many consecutive failures.
+# A broken/invalid API key returns 401 every call — the loop must not
+# run until the turn budget, wasting every turn on an unreachable judge.
+DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES = 5
CONTINUATION_PROMPT_TEMPLATE = (
@@ -399,6 +404,10 @@ class GoalState:
last_reason: Optional[str] = None
paused_reason: Optional[str] = None # why we auto-paused (budget, etc.)
consecutive_parse_failures: int = 0 # judge-output parse failures in a row
+ # Transport failures are API/auth/network errors. Broken API keys return
+ # 401 every call — track them separately so the loop auto-pauses instead
+ # of burning every turn budget slot on an unreachable judge.
+ consecutive_transport_failures: int = 0 # judge API/transport errors in a row
# User-added criteria appended mid-loop via the /subgoal command.
# When non-empty the judge prompt and continuation prompt both
# include them so the agent works toward them and the judge factors
@@ -457,6 +466,7 @@ class GoalState:
last_reason=data.get("last_reason"),
paused_reason=data.get("paused_reason"),
consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0),
+ consecutive_transport_failures=int(data.get("consecutive_transport_failures", 0) or 0),
subgoals=subgoals,
waiting_on_pid=(int(data["waiting_on_pid"]) if data.get("waiting_on_pid") else None),
waiting_on_session=(str(data["waiting_on_session"]) if data.get("waiting_on_session") else None),
@@ -841,17 +851,23 @@ def judge_goal(
subgoals: Optional[List[str]] = None,
background_processes: Optional[List[Dict[str, Any]]] = None,
contract: Optional[GoalContract] = None,
-) -> Tuple[str, str, bool, Optional[Dict[str, Any]]]:
+) -> Tuple[str, str, bool, Optional[Dict[str, Any]], bool]:
"""Ask the auxiliary model whether the goal is satisfied.
- Returns ``(verdict, reason, parse_failed, wait_directive)`` where verdict
+ Returns ``(verdict, reason, parse_failed, wait_directive, transport_failed)`` where verdict
is ``"done"``, ``"continue"``, ``"wait"``, or ``"skipped"`` (when the
judge couldn't be reached). ``wait_directive`` is set only for ``"wait"``
(``{"pid": int}`` or ``{"seconds": int}``); ``None`` otherwise.
``parse_failed`` is True only when the judge call succeeded but its output
was unusable (empty or non-JSON). API/transport errors return False — they
- are transient and should fail-open silently. Callers use this flag to
+ are transient and should fail-open silently.
+
+ ``transport_failed`` is True only when the judge couldn't reach the API at
+ all (auth 401, timeout, DNS, connection error). Repeated transport
+ failures signal a permanent config problem (e.g. invalid API key). Callers
+ use this flag to auto-pause after N consecutive transport failures (see
+ ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``). Callers use this flag to
auto-pause after N consecutive parse failures (see
``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).
@@ -867,21 +883,23 @@ def judge_goal(
judge prompt; when none are set, behavior is identical to the original
free-form judge.
- This is deliberately fail-open: any error returns ``("continue", ..., False, None)``
- so a broken judge doesn't wedge progress — the turn budget and the
- consecutive-parse-failures auto-pause are the backstops.
+ This is deliberately fail-open: transport errors return ``("continue", ..., ..., None, True)``
+ — the ``transport_failed=True`` flag lets callers track and auto-pause after
+ N consecutive transport failures (see
+ ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``) so a permanently broken
+ judge doesn't burn the entire turn budget.
"""
if not goal.strip():
- return "skipped", "empty goal", False, None
+ return "skipped", "empty goal", False, None, False
if not last_response.strip():
# No substantive reply this turn — almost certainly not done yet.
- return "continue", "empty response (nothing to evaluate)", False, None
+ return "continue", "empty response (nothing to evaluate)", False, None, False
try:
from agent.auxiliary_client import call_llm
except Exception as exc:
logger.debug("goal judge: auxiliary client import failed: %s", exc)
- return "continue", "auxiliary client unavailable", False, None
+ return "continue", "auxiliary client unavailable", False, None, False
# Build the prompt. Priority: contract > subgoals > plain. When both a
# contract and subgoals exist, the subgoals are appended into the
@@ -941,7 +959,7 @@ def judge_goal(
)
except Exception as exc:
logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
- return "continue", f"judge error: {type(exc).__name__}", False, None
+ return "continue", f"judge error: {type(exc).__name__}", False, None, True
try:
raw = resp.choices[0].message.content or ""
@@ -954,7 +972,7 @@ def judge_goal(
verdict, _truncate(reason, 120),
f" wait={wait_directive}" if wait_directive else "",
)
- return verdict, reason, parse_failed, wait_directive
+ return verdict, reason, parse_failed, wait_directive, False
def gather_background_processes(task_id: Optional[str] = None) -> List[Dict[str, Any]]:
@@ -1425,7 +1443,7 @@ class GoalManager:
state.turns_used += 1
state.last_turn_at = time.time()
- verdict, reason, parse_failed, wait_directive = judge_goal(
+ verdict, reason, parse_failed, wait_directive, transport_failed = judge_goal(
state.goal,
last_response,
subgoals=state.subgoals or None,
@@ -1443,6 +1461,16 @@ class GoalManager:
else:
state.consecutive_parse_failures = 0
+ # Track consecutive transport failures separately — persistent API
+ # errors (401 auth, DNS, timeout) signal a broken config, not
+ # transient network flakiness. Auto-pause after N consecutive
+ # transport failures so a permanently broken judge doesn't burn
+ # every turn budget slot on an unreachable API.
+ if transport_failed:
+ state.consecutive_transport_failures += 1
+ else:
+ state.consecutive_transport_failures = 0
+
# WAIT verdict: the judge decided the agent is blocked on async work
# and re-poking now would be busy-work. Set the barrier and park —
# the turn we just counted stands (the judge call happened), but no
@@ -1480,6 +1508,36 @@ class GoalManager:
"message": f"✓ Goal achieved: {reason}",
}
+ # Auto-pause when the judge cannot reach the API at all N turns in a
+ # row (401 auth, DNS failure, timeout). Persistent transport failures
+ # signal a broken configuration (e.g. invalid API key), not transient
+ # flakiness. Without this guard, a permanently broken judge burns
+ # every turn budget slot on an unreachable API.
+ if state.consecutive_transport_failures >= DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES:
+ state.status = "paused"
+ state.paused_reason = (
+ f"judge API unreachable {state.consecutive_transport_failures} turns in a row "
+ f"(check auxiliary.goal_judge provider/key in config.yaml)"
+ )
+ save_goal(self.session_id, state)
+ return {
+ "status": "paused",
+ "should_continue": False,
+ "continuation_prompt": None,
+ "verdict": "continue",
+ "reason": reason,
+ "message": (
+ f"⏸ Goal paused — judge API returned errors "
+ f"({state.consecutive_transport_failures} turns). "
+ "Check the goal_judge provider/key in ~/.hermes/config.yaml:\n"
+ " auxiliary:\n"
+ " goal_judge:\n"
+ " provider: deepseek\n"
+ " model: deepseek-v4-flash\n"
+ "Then /goal resume to continue."
+ ),
+ }
+
# Auto-pause when the judge model can't produce the expected JSON
# verdict N turns in a row. Points the user at the goal_judge config
# so they can route this side task to a model that follows the
@@ -1679,7 +1737,7 @@ def run_kanban_goal_loop(
# The kanban worker loop has no wait-barrier concept (workers finish
# via kanban_complete / kanban_block, not by parking), so a WAIT
# verdict is treated as CONTINUE here.
- verdict, reason, _parse_failed, _wait = judge_goal(goal_text, last_response)
+ verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response)
if verdict == "wait":
verdict = "continue"
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py
index cd90d2a9a9e7..cd9817bb5a1b 100644
--- a/hermes_cli/inventory.py
+++ b/hermes_cli/inventory.py
@@ -52,6 +52,7 @@ class ConfigContext:
current_base_url: str
user_providers: dict
custom_providers: list
+ excluded_providers: list = None
def with_overrides(
self,
@@ -96,12 +97,14 @@ def load_picker_context() -> ConfigContext:
current_provider = ""
current_base_url = ""
raw = cfg.get("providers")
+ excluded = cfg.get("model_catalog", {}).get("excluded_providers") or []
return ConfigContext(
current_provider=current_provider,
current_model=current_model,
current_base_url=current_base_url,
user_providers=raw if isinstance(raw, dict) else {},
custom_providers=get_compatible_custom_providers(cfg),
+ excluded_providers=excluded if isinstance(excluded, list) else [],
)
@@ -179,6 +182,7 @@ def build_models_payload(
refresh=refresh,
probe_custom_providers=probe_custom_providers,
probe_current_custom_provider=probe_current_custom_provider,
+ excluded_providers=ctx.excluded_providers or [],
)
moa_row = _moa_provider_row(ctx.current_provider)
diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py
index 4de2f8fb8b66..c6f9688ffe5e 100644
--- a/hermes_cli/kanban.py
+++ b/hermes_cli/kanban.py
@@ -1996,6 +1996,50 @@ def _cmd_complete(args: argparse.Namespace) -> int:
failed: list[str] = []
with kb.connect_closing() as conn:
for tid in ids:
+ # Goal-mode pre-completion judge gate (mirrors the gate in
+ # tools/kanban_tools.py:_handle_complete — Issue #38367).
+ # Without this, a goal_mode worker can call
+ # `hermes kanban complete ` from the terminal tool and
+ # bypass the auxiliary judge that the tool-call path enforces.
+ task = kb.get_task(conn, tid)
+ if task and task.goal_mode:
+ judge_available = False
+ try:
+ from agent.auxiliary_client import get_text_auxiliary_client
+ _client, _model = get_text_auxiliary_client("goal_judge")
+ judge_available = _client is not None and bool(_model)
+ except Exception:
+ pass
+ if judge_available:
+ from hermes_cli.goals import judge_goal
+ verdict = "done"
+ reason = ""
+ try:
+ # judge_goal returns (verdict, reason, parse_failed,
+ # wait_directive, transport_failed) — see
+ # hermes_cli/goals.py. Unpacking fewer raises
+ # ValueError into the fail-open handler below,
+ # silently disabling the gate.
+ verdict, reason, _, _, _ = judge_goal(
+ goal=f"{task.title}\n\n{task.body or ''}".strip(),
+ last_response=(summary or args.result or "").strip(),
+ )
+ except Exception as judge_exc:
+ import logging as _logging
+ _logging.getLogger(__name__).warning(
+ "goal judge check failed, allowing completion: %s",
+ judge_exc,
+ exc_info=True,
+ )
+ if verdict != "done":
+ print(
+ f"kanban: goal completion of {tid} rejected by judge: {reason}. "
+ f"Provide evidence matching the task's acceptance criteria.",
+ file=sys.stderr,
+ )
+ failed.append(tid)
+ continue
+
if not kb.complete_task(
conn, tid,
result=args.result,
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 88f5fa375bf1..526fa9345161 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -65,6 +65,125 @@ import os
import sys
+def _exit_after_oneshot(rc: object) -> None:
+ """Exit one-shot mode without letting late native finalizers change rc.
+
+ The SIGABRT this guards against (#30387, #43055) fires in a
+ native-extension finalizer during CPython's ``Py_FinalizeEx``, *after*
+ the response has printed. Flush streams, shut down file logging, then
+ ``os._exit`` past interpreter finalization. The ``atexit`` chain is
+ deliberately skipped — several handlers re-enter native code that may
+ be the abort source. Stateful cleanup is handled in ``_run_agent`` and
+ ``_cleanup_oneshot_runtime``.
+ """
+ for stream in (sys.stdout, sys.stderr):
+ try:
+ stream.flush()
+ except Exception:
+ pass
+ try:
+ logging.shutdown()
+ except Exception:
+ pass
+ if rc is None:
+ exit_code = 0
+ elif isinstance(rc, int):
+ exit_code = rc
+ else:
+ exit_code = 1
+ os._exit(exit_code)
+
+
+_oneshot_cleanup_done = False
+
+
+def _cleanup_oneshot_runtime() -> None:
+ """Best-effort process-global cleanup before one-shot hard exit.
+
+ ``run_oneshot`` owns the agent-local cleanup (memory provider, agent.close,
+ session_db.close — all in ``_run_agent``'s finally block). This mirrors the
+ process-global pieces from ``cli.py:_run_cleanup()`` that would otherwise
+ be skipped by ``os._exit``.
+ """
+ global _oneshot_cleanup_done
+ if _oneshot_cleanup_done:
+ return
+ _oneshot_cleanup_done = True
+ try:
+ from tools.terminal_tool import cleanup_all_environments
+ cleanup_all_environments()
+ except Exception:
+ pass
+ try:
+ from tools.async_delegation import interrupt_all
+ interrupt_all(reason="oneshot shutdown")
+ except Exception:
+ pass
+ try:
+ from tools.browser_tool import _emergency_cleanup_all_sessions
+ _emergency_cleanup_all_sessions()
+ except Exception:
+ pass
+ try:
+ from tools.mcp_tool import shutdown_mcp_servers
+ shutdown_mcp_servers()
+ except BaseException:
+ pass
+ try:
+ from agent.auxiliary_client import shutdown_cached_clients
+ shutdown_cached_clients()
+ except Exception:
+ pass
+
+
+def _run_and_exit_oneshot(
+ prompt: str,
+ *,
+ model: object = None,
+ provider: object = None,
+ toolsets: object = None,
+ usage_file: object = None,
+) -> None:
+ try:
+ from hermes_cli.oneshot import run_oneshot
+
+ rc = run_oneshot(
+ prompt,
+ model=model,
+ provider=provider,
+ toolsets=toolsets,
+ usage_file=usage_file,
+ )
+ except KeyboardInterrupt:
+ rc = 130
+ except SystemExit as exc:
+ if exc.code is not None and not isinstance(exc.code, int):
+ print(exc.code, file=sys.stderr)
+ rc = 1
+ else:
+ rc = exc.code
+ except BaseException:
+ # Defense-in-depth. ``run_oneshot`` already converts agent failures
+ # into an int return code and only re-raises KeyboardInterrupt /
+ # SystemExit (handled above). Anything still escaping here means
+ # ``run_oneshot`` itself malfunctioned — surface it on stderr but never
+ # fall through to normal interpreter teardown, which is the exact path
+ # that aborts with SIGABRT on AL2023 (the bug this routine fixes).
+ import traceback
+ try:
+ traceback.print_exc()
+ except Exception:
+ pass
+ rc = 1
+ try:
+ _cleanup_oneshot_runtime()
+ finally:
+ # The hard exit is the safety boundary for #43055. Even an interrupt
+ # during best-effort cleanup must not fall back into interpreter
+ # finalization, where the reported native SIGABRT occurs.
+ _exit_after_oneshot(rc)
+
+
def _set_process_title() -> None:
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
'htop' show the app name instead of 'python3.xx'.
@@ -3048,6 +3167,7 @@ def select_provider_and_model(args=None):
from hermes_cli.models import (
CANONICAL_PROVIDERS,
_PROVIDER_LABELS,
+ _PROVIDER_ALIASES,
group_providers,
provider_group_for_slug,
)
@@ -3071,7 +3191,30 @@ def select_provider_and_model(args=None):
# resolves back to a concrete slug, so the dispatch chain below is
# unchanged. Custom providers and the trailing actions stay flat.
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
- grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
+ # Honor ``model_catalog.excluded_providers`` so the CLI ``hermes model``
+ # picker hides the same providers the gateway/TUI pickers do. A canonical
+ # provider is hidden if its slug OR any of its aliases appears in the
+ # exclusion list (case-insensitive), matching list_authenticated_providers'
+ # matching against hermes_id / alias / canonical slug.
+ _cli_excluded = {
+ str(p).strip().lower()
+ for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or []
+ if p
+ }
+ if _cli_excluded:
+ _alias_to_canon = _PROVIDER_ALIASES
+ _names_for: dict[str, set[str]] = {}
+ for _p in CANONICAL_PROVIDERS:
+ _names_for[_p.slug] = {_p.slug.lower()}
+ for _alias, _canon in _alias_to_canon.items():
+ _names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower())
+ _visible_slugs = [
+ p.slug for p in CANONICAL_PROVIDERS
+ if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded
+ ]
+ else:
+ _visible_slugs = [p.slug for p in CANONICAL_PROVIDERS]
+ grouped_rows = group_providers(_visible_slugs)
# The group/slug that should be pre-selected: the active provider's group
# if it's grouped, otherwise the active slug itself.
@@ -12973,16 +13116,12 @@ def _try_termux_fast_cli_launch() -> bool:
if getattr(args, "oneshot", None):
_prepare_agent_startup(args)
- from hermes_cli.oneshot import run_oneshot
-
- sys.exit(
- run_oneshot(
- args.oneshot,
- model=getattr(args, "model", None),
- provider=getattr(args, "provider", None),
- toolsets=getattr(args, "toolsets", None),
- usage_file=getattr(args, "usage_file", None),
- )
+ _run_and_exit_oneshot(
+ args.oneshot,
+ model=getattr(args, "model", None),
+ provider=getattr(args, "provider", None),
+ toolsets=getattr(args, "toolsets", None),
+ usage_file=getattr(args, "usage_file", None),
)
if (args.resume or args.continue_last) and args.command is None:
@@ -15130,16 +15269,12 @@ def main():
# Handle top-level --oneshot / -z: single-shot mode, stdout = final
# response only, nothing else. Bypasses cli.py entirely.
if getattr(args, "oneshot", None):
- from hermes_cli.oneshot import run_oneshot
-
- sys.exit(
- run_oneshot(
- args.oneshot,
- model=getattr(args, "model", None),
- provider=getattr(args, "provider", None),
- toolsets=getattr(args, "toolsets", None),
- usage_file=getattr(args, "usage_file", None),
- )
+ _run_and_exit_oneshot(
+ args.oneshot,
+ model=getattr(args, "model", None),
+ provider=getattr(args, "provider", None),
+ toolsets=getattr(args, "toolsets", None),
+ usage_file=getattr(args, "usage_file", None),
)
# Handle top-level --resume / --continue as shortcut to chat
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
index fcb669171627..a86b1316e99d 100644
--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -101,6 +101,62 @@ def _declared_model_ids(value: Any) -> list[str]:
return ids
+def _save_discovered_models_to_config(
+ api_url: str, model_ids: list[str]
+) -> None:
+ """Persist discovered models into ``custom_providers`` in config.yaml.
+
+ Called after a successful ``/v1/models`` probe so that the next read
+ with ``discover_models: false`` uses the cached list instead of a stale
+ or minimal manually-configured subset.
+
+ Matches entries by ``base_url`` (trailing-slash-normalised). A failed
+ config write is swallowed — the picker still shows the live models for
+ this session.
+ """
+ if not api_url or not model_ids:
+ return
+ try:
+ from hermes_cli.config import load_config, save_config
+
+ cfg = load_config()
+ providers = cfg.get("custom_providers") or []
+ if not isinstance(providers, list):
+ return
+
+ norm_url = api_url.strip().rstrip("/").lower()
+ changed = False
+ for entry in providers:
+ if not isinstance(entry, dict):
+ continue
+ entry_url = (entry.get("base_url", "") or entry.get("url", "") or "").strip()
+ if entry_url.rstrip("/").lower() != norm_url:
+ continue
+ existing = entry.get("models")
+ # Preserve per-model metadata: when ``models`` is a mapping
+ # (e.g. ``{"model-a": {"context_length": 8192}}``) or a list of
+ # dicts (e.g. ``[{"id": "model-a", "context_length": 8192}]``),
+ # the user has curated metadata per model — do not replace it.
+ if isinstance(existing, dict):
+ continue
+ if isinstance(existing, list) and any(
+ isinstance(m, dict) for m in existing
+ ):
+ continue
+ # Only update when models are stale — avoids unnecessary
+ # config writes on every picker open.
+ if isinstance(existing, list) and existing == model_ids:
+ continue
+ entry["models"] = model_ids
+ changed = True
+
+ if changed:
+ cfg["custom_providers"] = providers
+ save_config(cfg)
+ except Exception:
+ pass
+
+
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
base_url = str(current_base_url or "").strip()
@@ -144,6 +200,51 @@ _NOUS_HERMES_NON_AGENTIC_RE = re.compile(
)
+# Opaque internal model-ID display
+# ---------------------------------------------------------------------------
+# Some proxies (notably Palantir Foundry's LLM-proxy) identify models by
+# resource-instance IDs that are deeply nested, verbose, and pure noise to
+# read in CLI status output, e.g.:
+#
+# ri.language-model-service..language-model.anthropic-claude-4-7-opus
+#
+# The provider_label (e.g. "palantir-claude46") already carries the routing
+# context, so the only useful information left in the opaque ID is the
+# trailing slug. Strip the boilerplate prefix for *display* — never for
+# wire-side comparison, persistence, config writes, alias lookup, or
+# anything that round-trips back into the API.
+#
+# Match by substring on a known prefix so we never accidentally truncate
+# a legitimate model name that happens to contain dots.
+
+_OPAQUE_MODEL_PREFIXES: tuple[str, ...] = (
+ "ri.language-model-service..language-model.",
+)
+
+
+def format_model_for_display(model_name: str) -> str:
+ """Return a human-friendly form of *model_name* for CLI status output.
+
+ Strips known opaque proxy prefixes (Palantir Foundry's
+ ``ri.language-model-service..language-model.*``) and returns the
+ trailing slug. Falls through to the original string for everything
+ else, so real model IDs (``claude-4-7-opus-20260101``,
+ ``gpt-5-4``, ``meta-llama/Llama-3.3-70B-Instruct``) are untouched.
+
+ This is a DISPLAY-ONLY helper. Do NOT use the return value for any
+ wire-side operation — the proxy expects the full opaque ID, and
+ callers that compare or persist must keep the original.
+ """
+ if not model_name:
+ return model_name
+ for prefix in _OPAQUE_MODEL_PREFIXES:
+ if model_name.startswith(prefix):
+ tail = model_name[len(prefix):]
+ return tail if tail else model_name
+ return model_name
+
+
+# ---------------------------------------------------------------------------
def is_nous_hermes_non_agentic(model_name: str) -> bool:
"""Return True if *model_name* is a real Nous Hermes 3/4 chat model.
@@ -451,7 +552,12 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:
)
-def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool = False) -> bool:
+def resolve_persist_behavior(
+ is_global: bool,
+ is_session: bool,
+ is_once: bool = False,
+ explicit_provider: str = "",
+) -> bool:
"""Decide whether a ``/model`` switch should persist to ``config.yaml``.
Resolution order:
@@ -459,13 +565,19 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =
1. ``--once`` explicitly opts out → ``False`` (next turn only).
2. ``--session`` explicitly opts out → ``False`` (this session only).
3. ``--global`` explicitly opts in → ``True``.
- 4. Otherwise defer to ``model.persist_switch_by_default`` in
- ``config.yaml`` (defaults to ``True``, so a plain ``/model ``
- survives across sessions — the behavior users expect).
+ 4. ``--provider`` given without an explicit persist flag → ``False``
+ (session only). Provider switches are typically exploratory — the
+ user is trying a different backend for this conversation, not
+ reconfiguring the default. ``--global`` can still force persist.
+ 5. Otherwise defer to ``model.persist_switch_by_default`` in
+ ``config.yaml`` (defaults to ``False``: a plain ``/model ``
+ affects only the current session). Users who want the old
+ persist-by-default behavior can set the key to ``true``; a one-off
+ ``--global`` always persists.
The config read is defensive: on a fresh install ``model`` may be a
flat string rather than a dict, in which case the built-in default
- (``True``) applies.
+ (``False``) applies.
"""
if is_once:
return False
@@ -473,15 +585,17 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =
return False
if is_global:
return True
+ if explicit_provider:
+ return False
try:
from hermes_cli.config import load_config
model_cfg = load_config().get("model")
if isinstance(model_cfg, dict):
- return bool(model_cfg.get("persist_switch_by_default", True))
+ return bool(model_cfg.get("persist_switch_by_default", False))
except Exception:
pass
- return True
+ return False
# ---------------------------------------------------------------------------
@@ -1336,8 +1450,11 @@ def switch_model(
if not validation.get("accepted"):
override = False
if user_providers:
+ from hermes_cli.config import is_provider_enabled
# user_providers is a dict: {provider_slug: config_dict}
for slug, cfg in user_providers.items():
+ if not is_provider_enabled(cfg):
+ continue
if slug == target_provider:
if new_model in _declared_model_ids(cfg.get("models", {})):
override = True
@@ -1514,6 +1631,7 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
+ excluded_providers=ctx.excluded_providers or [],
)
except Exception:
# Best-effort warmup — never surface errors into the session.
@@ -1537,6 +1655,7 @@ def list_authenticated_providers(
probe_custom_providers: bool = True,
probe_current_custom_provider: bool = False,
for_picker: bool = False,
+ excluded_providers: list | None = None,
) -> List[dict]:
"""Detect which providers have credentials and list their curated models.
@@ -1601,13 +1720,17 @@ def list_authenticated_providers(
results: List[dict] = []
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
- seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
_current_provider_norm = str(current_provider or "").strip().lower()
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
def _can_probe_custom_provider(*, row_is_current: bool) -> bool:
return bool(probe_custom_providers or (probe_current_custom_provider and row_is_current))
+ # Normalize the excluded-providers list once for fast membership checks.
+ # Compared against hermes_id / mdev_id (section 1), pid / hermes_slug
+ # (section 2) and canonical slug (section 2b) so a single entry like
+ # ``copilot`` hides the provider regardless of which key it surfaces under.
+ _excluded: set = {str(p).strip().lower() for p in (excluded_providers or []) if p}
# Effective base URLs of every built-in row we emit (normalized lower+rstrip).
# Section 4 uses this to hide ``custom_providers`` entries that point at the
# same endpoint as a built-in (e.g. a user-defined "my-dashscope" on
@@ -1725,6 +1848,7 @@ def list_authenticated_providers(
# --- 1. Check Hermes-mapped providers ---
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
+ from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
# Skip vendor names that are merely aliases routing through an
@@ -1742,10 +1866,32 @@ def list_authenticated_providers(
and _alias_target in _AGG_PROVIDERS
):
continue
- # Skip aliases that map to the same models.dev provider (e.g.
- # kimi-coding and kimi-coding-cn both → kimi-for-coding).
- # The first one with valid credentials wins (#10526).
- if mdev_id in seen_mdev_ids:
+ # Resolve the canonical provider profile name. Skip hermes_ids
+ # that are mere aliases resolving to a different canonical profile
+ # (e.g. "kimi" and "moonshot" both → "kimi-coding"). Only process
+ # entries whose hermes_id matches the canonical profile name so
+ # distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get
+ # their own picker row.
+ _canonical = hermes_id
+ try:
+ from providers import get_provider_profile as _gpp
+ _prof = _gpp(hermes_id)
+ if _prof is not None:
+ _canonical = _prof.name
+ except Exception:
+ pass
+ if _canonical != hermes_id:
+ continue
+
+ # Skip duplicates: another entry with the same slug was already
+ # emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the
+ # same hermes_id). Distinct canonical profiles that share a
+ # models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding)
+ # are both allowed through since they have different slugs.
+ slug = hermes_id
+ if slug.lower() in seen_slugs:
+ continue
+ if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded:
continue
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
@@ -1814,21 +1960,23 @@ def list_authenticated_providers(
else:
top = model_ids[:max_models] if max_models is not None else model_ids
- slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
- display_name = pinfo.name if pinfo else mdev_id
+ display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id)
results.append({
"slug": slug,
"name": display_name,
- "is_current": slug == current_provider or mdev_id == current_provider,
+ "is_current": (
+ slug == current_provider
+ or hermes_id == current_provider
+ or mdev_id == current_provider
+ ),
"is_user_defined": False,
"models": top,
"total_models": total,
"source": "built-in",
})
seen_slugs.add(slug.lower())
- seen_mdev_ids.add(mdev_id)
_record_builtin_endpoint(slug)
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
@@ -1848,6 +1996,8 @@ def list_authenticated_providers(
hermes_slug = _mdev_to_hermes.get(pid, pid)
if hermes_slug.lower() in seen_slugs:
continue
+ if pid.lower() in _excluded or hermes_slug.lower() in _excluded:
+ continue
# Check if credentials exist
has_creds = False
@@ -2017,6 +2167,8 @@ def list_authenticated_providers(
for _cp in _canon_provs:
if _cp.slug.lower() in seen_slugs:
continue
+ if _cp.slug.lower() in _excluded:
+ continue
# Check credentials via PROVIDER_REGISTRY (auth.py)
_cp_config = _auth_registry.get(_cp.slug)
@@ -2086,37 +2238,121 @@ def list_authenticated_providers(
# and one "custom:openrouter" from section 4, both labelled identically.
_section3_emitted_pairs: set = set()
if user_providers and isinstance(user_providers, dict):
+ # Group ``providers:`` entries by (api_url, key_env, api_mode) so that
+ # multiple keyed providers pointing at the same endpoint with the
+ # same credential and wire-protocol collapse into one picker row.
+ # Mirrors section-4's grouping for ``custom_providers:`` lists.
+ # Concrete case: a Palantir Foundry Anthropic-proxy with two
+ # configured models (claude-4.6 + claude-4.7) — both share the same
+ # api/key_env/api_mode and used to produce two near-duplicate rows
+ # labelled "Palantir Claude 4.6 Opus" and "Palantir Claude 4.7 Opus";
+ # now they appear as a single "Palantir Claude" row with both models
+ # in the dropdown. Same-host entries with different ``key_env`` or
+ # ``api_mode`` (e.g. an OpenAI-compat gpt-5.4 alongside the Anthropic
+ # claude-4.7 on the same Palantir host) keep distinct rows since
+ # the wire protocol differs.
+ from collections import OrderedDict as _OD3
+
+ from hermes_cli.config import is_provider_enabled
+
+ ep_groups: "_OD3[tuple, dict]" = _OD3()
for ep_name, ep_cfg in user_providers.items():
if not isinstance(ep_cfg, dict):
continue
- # Skip if this slug was already emitted (e.g. canonical provider
- # with the same name) or will be picked up by section 4.
+ # Honour explicit ``providers..enabled: false`` from
+ # config — these are hidden from the picker.
+ if not is_provider_enabled(ep_cfg):
+ continue
if ep_name.lower() in seen_slugs:
continue
display_name = ep_cfg.get("name", "") or ep_name
- # ``base_url`` is Hermes's canonical write key (matches
- # custom_providers and _save_custom_provider); ``api`` / ``url``
- # remain as fallbacks for hand-edited / legacy configs.
api_url = (
ep_cfg.get("base_url", "")
or ep_cfg.get("api", "")
or ep_cfg.get("url", "")
or ""
)
+ key_env = str(ep_cfg.get("key_env", "") or "").strip()
+ inline_api_key = str(ep_cfg.get("api_key", "") or "").strip()
+ api_mode = str(
+ ep_cfg.get("api_mode")
+ or ep_cfg.get("transport")
+ or ""
+ ).strip().lower()
+ credential_identity = (
+ inline_api_key
+ if inline_api_key
+ else (f"env:{key_env}" if key_env else "")
+ )
+ api_url_norm = str(api_url).strip().rstrip("/").lower()
+ # Per-provider extra_headers participate in the group identity
+ # (same invariant as section 4): two entries sharing
+ # (api_url, credential, api_mode) but declaring different headers
+ # are distinct endpoints (e.g. different tenants behind one proxy
+ # URL, routed by header) and must keep distinct picker rows.
+ entry_extra_headers = _extra_headers_from_config(ep_cfg)
+ headers_identity = tuple(sorted(entry_extra_headers.items()))
+ group_key = (api_url_norm, credential_identity, api_mode, headers_identity)
+
# ``default_model`` is the legacy key; ``model`` matches what
# custom_providers entries use, so accept either.
default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "")
-
- # Build models list from both default_model and full models array
- models_list = []
- if default_model:
- models_list.append(default_model)
- # Also include the full models list from config.
+ # Build models list from both default_model and full models array.
# Hermes writes ``models:`` as a dict keyed by model id, but older
- # or hand-edited configs may use strings or ``[{id: ...}]`` rows.
+ # or hand-edited configs may use strings or ``[{id: ...}]`` rows —
+ # _declared_model_ids() owns that contract.
+ entry_models: list = []
+ if default_model:
+ entry_models.append(default_model)
for model_id in _declared_model_ids(ep_cfg.get("models", [])):
- if model_id not in models_list:
- models_list.append(model_id)
+ if model_id not in entry_models:
+ entry_models.append(model_id)
+
+ if group_key not in ep_groups:
+ # Strip per-model suffix so "Palantir Claude 4.7 Opus" becomes
+ # "Palantir Claude". Em dash and " - " are the separators
+ # Hermes's own writer uses (mirrors section-4 grouping).
+ grp_display = display_name
+ for sep in ("—", " - "):
+ if sep in grp_display:
+ grp_display = grp_display.split(sep)[0].strip()
+ break
+ # Drop trailing numeric/version tokens that distinguish per-model
+ # entries ("Palantir Claude 4.7 Opus" → "Palantir Claude").
+ # Keeps the row label short; the model dropdown carries the
+ # per-version detail. Heuristic: split at the first token whose
+ # stripped form contains a digit; keep the prefix only if it
+ # is at least 2 words (avoids over-trimming single-word names).
+ _toks = grp_display.split()
+ _cut_at = None
+ for _i, _t in enumerate(_toks):
+ _tl = _t.strip(".,()")
+ if _tl and any(c.isdigit() for c in _tl):
+ _cut_at = _i
+ break
+ if _cut_at is not None and _cut_at >= 2:
+ grp_display = " ".join(_toks[:_cut_at]).strip()
+ grp_slug = ep_name # primary slug is the first ep_name encountered
+ ep_groups[group_key] = {
+ "slug": grp_slug,
+ "name": grp_display or display_name,
+ "api_url": api_url,
+ "models": [],
+ "ep_cfg": ep_cfg, # used below for discover_models / api_key
+ "raw_names": [],
+ }
+ # Aggregate models across all members of the group (preserve order).
+ for _m in entry_models:
+ if _m and _m not in ep_groups[group_key]["models"]:
+ ep_groups[group_key]["models"].append(_m)
+ ep_groups[group_key]["raw_names"].append(display_name)
+
+ for grp in ep_groups.values():
+ ep_cfg = grp["ep_cfg"]
+ ep_name = grp["slug"]
+ display_name = grp["name"]
+ api_url = grp["api_url"]
+ models_list = list(grp["models"])
# Official OpenAI API rows in providers: often have base_url but no
# explicit models: dict — avoid a misleading zero count in /model.
@@ -2184,9 +2420,22 @@ def list_authenticated_providers(
})
seen_slugs.add(ep_name.lower())
seen_slugs.add(custom_provider_slug(display_name).lower())
+ # Record (display_name, api_url) for each raw entry that joined
+ # this group so section-4's _section3_emitted_pairs dedup can
+ # match per-model custom_providers rows ("Palantir Claude 4.7 Opus")
+ # even though we collapsed the group label to "Palantir Claude".
+ _url_norm_for_pair = str(api_url).strip().rstrip("/").lower()
+ for _raw_name in grp.get("raw_names") or [display_name]:
+ _pair = (
+ str(_raw_name).strip().lower(),
+ _url_norm_for_pair,
+ )
+ if _pair[0] and _pair[1]:
+ _section3_emitted_pairs.add(_pair)
+ seen_slugs.add(custom_provider_slug(_raw_name).lower())
_pair = (
str(display_name).strip().lower(),
- str(api_url).strip().rstrip("/").lower(),
+ _url_norm_for_pair,
)
if _pair[0] and _pair[1]:
_section3_emitted_pairs.add(_pair)
@@ -2248,11 +2497,17 @@ def list_authenticated_providers(
if custom_providers and isinstance(custom_providers, list):
from collections import OrderedDict
- # Key by endpoint + credential identity + wire protocol instead of
- # slug: names frequently differ per model ("Ollama — X") while the
- # endpoint stays the same. Keep same-host providers with distinct
- # env-backed credentials or API protocols separate so picker selection
- # cannot route through the wrong credential/mode pair.
+ # Key by endpoint + credential identity + wire protocol + display
+ # prefix instead of slug: names frequently differ per model
+ # ("Ollama — X") while the endpoint stays the same. Keep same-host
+ # providers with distinct env-backed credentials or API protocols
+ # separate so picker selection cannot route through the wrong
+ # credential/mode pair. The display prefix (text before " — " /
+ # " - ") is included so intentionally distinct providers sharing an
+ # endpoint (e.g. a proxy fronting cerebras, groq and perplexity at
+ # a single base_url) each get their own picker row instead of
+ # collapsing into one. Per-model suffix entries that share the same
+ # prefix ("Ollama — A", "Ollama — B") still group together.
groups: "OrderedDict[tuple, dict]" = OrderedDict()
for entry in custom_providers:
if not isinstance(entry, dict):
@@ -2299,19 +2554,19 @@ def list_authenticated_providers(
entry_extra_headers = _extra_headers_from_config(entry)
headers_identity = tuple(sorted(entry_extra_headers.items()))
- group_key = (api_url, credential_identity, api_mode, headers_identity)
+ # Display-name prefix (text before " — " / " - "), used both
+ # as a grouping dimension and to derive the row's display name.
+ _display_prefix = raw_name
+ for sep in ("—", " - "):
+ if sep in _display_prefix:
+ _display_prefix = _display_prefix.split(sep)[0].strip()
+ break
+
+ group_key = (api_url, credential_identity, api_mode, headers_identity, _display_prefix.lower())
if group_key not in groups:
- # Strip per-model suffix so "Ollama — GLM 5.1" becomes
- # "Ollama" for the grouped row. Em dash is the convention
- # Hermes's own writer uses; a hyphen variant is accepted
- # for hand-edited configs.
- display_name = raw_name
- for sep in ("—", " - "):
- if sep in display_name:
- display_name = display_name.split(sep)[0].strip()
- break
- if not display_name:
- display_name = raw_name
+ # Reuse the prefix computed above as the row display name;
+ # fall back to the raw name if stripping left it empty.
+ display_name = _display_prefix or raw_name
slug = custom_provider_slug(display_name)
groups[group_key] = {
"slug": slug,
@@ -2447,6 +2702,12 @@ def list_authenticated_providers(
if live_models:
grp["models"] = live_models
grp["total_models"] = len(live_models)
+ # Auto-save discovered models back to config so
+ # ``discover_models: false`` has a populated cache
+ # on the next read. A failed save is non-fatal.
+ _save_discovered_models_to_config(
+ api_url, live_models
+ )
except Exception:
pass
results.append({
@@ -2462,6 +2723,28 @@ def list_authenticated_providers(
seen_slugs.add(slug.lower())
_section4_emitted_slugs.add(slug.lower())
+ # Apply final ``providers..enabled: false`` post-filter — covers
+ # built-in PROVIDER_REGISTRY rows (sections 1-2) which would otherwise
+ # bypass the per-section gate. Indexed by lowercase slug AND by
+ # ``provider_id`` so PROVIDER_REGISTRY entries that match user-config
+ # blocks are filtered consistently.
+ try:
+ from hermes_cli.config import is_provider_enabled
+ if isinstance(user_providers, dict):
+ _disabled_slugs = {
+ str(name).strip().lower()
+ for name, cfg in user_providers.items()
+ if isinstance(cfg, dict) and not is_provider_enabled(cfg)
+ }
+ if _disabled_slugs:
+ results = [
+ r for r in results
+ if str(r.get("provider_id", "")).strip().lower() not in _disabled_slugs
+ and str(r.get("slug", "")).strip().lower() not in _disabled_slugs
+ ]
+ except Exception:
+ pass
+
# Surface a custom / uncurated model the user selected via the CLI.
# Each row's model list is its curated/live catalog, so a model the user set
# with `/model /` would otherwise be invisible in
@@ -2514,6 +2797,7 @@ def list_picker_providers(
max_models: int | None = None,
current_model: str = "",
include_moa: bool = False,
+ excluded_providers: list | None = None,
) -> List[dict]:
"""Interactive-picker variant of :func:`list_authenticated_providers`.
@@ -2544,6 +2828,7 @@ def list_picker_providers(
max_models=max_models,
current_model=current_model,
for_picker=True,
+ excluded_providers=excluded_providers,
)
if include_moa:
providers = _prepend_moa_picker_provider(providers, current_provider=current_provider)
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index 389bfa0a5bd9..dbb14e05f684 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -281,6 +281,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gpt-4o",
"gpt-4o-mini",
"claude-sonnet-4.6",
+ "claude-sonnet-5",
"claude-sonnet-4",
"claude-sonnet-4.5",
"claude-haiku-4.5",
@@ -318,6 +319,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"minimaxai/minimax-m3",
],
"kimi-coding": [
+ "kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
@@ -329,6 +331,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-0905-preview",
],
"kimi-coding-cn": [
+ "kimi-k3",
+ "kimi-k2.7-code",
+ "kimi-k2.7-code-highspeed",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
@@ -340,6 +345,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"step-3.5-flash-2603",
],
"moonshot": [
+ "kimi-k3",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
@@ -367,6 +373,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
],
"anthropic": [
"claude-fable-5",
+ "claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
@@ -403,6 +410,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"google/gemini-3.1-flash-lite-preview",
+ "anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
],
@@ -427,6 +435,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gpt-5-codex",
"gpt-5-nano",
"claude-fable-5",
+ "claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
@@ -459,6 +468,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"nemotron-3-ultra-free",
],
"opencode-go": [
+ "kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
@@ -493,6 +503,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
"alibaba": [
"qwen3.7-max",
+ "qwen3.7-plus",
"qwen3.6-plus",
"kimi-k2.5",
"qwen3.5-plus",
@@ -506,9 +517,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl),
# separate provider ID with its own base_url_env_var.
"alibaba-coding-plan": [
- "qwen3.7-max",
+ "qwen3.7-plus",
"qwen3.6-plus",
"qwen3.5-plus",
+ "qwen3-max-2026-01-23",
"qwen3-coder-plus",
"qwen3-coder-next",
"kimi-k2.5",
@@ -533,6 +545,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# prefers live discovery via ListFoundationModels + ListInferenceProfiles.
# Use inference profile IDs (us.*) since most models require them.
"bedrock": [
+ "us.anthropic.claude-sonnet-5",
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-4-6-v1",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
@@ -1141,6 +1154,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {
"xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]),
"google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]),
"openai": ("OpenAI", "Codex CLI or direct OpenAI API", ["openai-codex", "openai-api"]),
+ "qwen": ("Qwen", "Qwen Cloud / DashScope, Coding Plan & Qwen CLI OAuth", ["alibaba", "alibaba-coding-plan", "qwen-oauth"]),
"opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]),
"copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]),
}
@@ -3377,6 +3391,7 @@ _COPILOT_MODEL_ALIASES = {
"openai/o3-mini": "gpt-5-mini",
"openai/o4-mini": "gpt-5-mini",
"anthropic/claude-opus-4.6": "claude-opus-4.6",
+ "anthropic/claude-sonnet-5": "claude-sonnet-5",
"anthropic/claude-sonnet-4.6": "claude-sonnet-4.6",
"anthropic/claude-sonnet-4": "claude-sonnet-4",
"anthropic/claude-sonnet-4.5": "claude-sonnet-4.5",
@@ -3386,12 +3401,14 @@ _COPILOT_MODEL_ALIASES = {
# dot-notation. Accept both so users who configure copilot + a
# default hyphenated Claude model don't hit HTTP 400
# "model_not_supported". See issue #6879.
+ "claude-sonnet-5": "claude-sonnet-5",
"claude-opus-4-6": "claude-opus-4.6",
"claude-sonnet-4-6": "claude-sonnet-4.6",
"claude-sonnet-4-0": "claude-sonnet-4",
"claude-sonnet-4-5": "claude-sonnet-4.5",
"claude-haiku-4-5": "claude-haiku-4.5",
"anthropic/claude-opus-4-6": "claude-opus-4.6",
+ "anthropic/claude-sonnet-5": "claude-sonnet-5",
"anthropic/claude-sonnet-4-6": "claude-sonnet-4.6",
"anthropic/claude-sonnet-4-0": "claude-sonnet-4",
"anthropic/claude-sonnet-4-5": "claude-sonnet-4.5",
diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py
index 0ed1dcd303a1..e0c1337698ce 100644
--- a/hermes_cli/oneshot.py
+++ b/hermes_cli/oneshot.py
@@ -188,7 +188,7 @@ def run_oneshot(
run — even when the run fails — so pipelines can account for
spend per invocation.
- Returns the exit code. Caller should sys.exit() with the return.
+ Returns the exit code. The caller owns process termination.
"""
# Silence every stdlib logger for the duration. AIAgent, tools, and
# provider adapters all log to stderr through the root logger; file
@@ -396,44 +396,76 @@ def _run_agent(
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
session_db = _create_session_db_for_oneshot()
- # Read the effective fallback chain from profile config so oneshot workers
- # honour the same merge semantics as interactive CLI and gateway sessions.
- _fb = get_fallback_chain(cfg)
+ # The try spans agent construction (not just ``chat``) so the SQLite store
+ # opened above is always closed — including when ``AIAgent(...)`` itself
+ # raises on a provider/config error. The one-shot exit path hard-exits via
+ # os._exit and skips finalizers, so an un-closed connection here would leak.
+ agent = None
+ try:
+ # Read the effective fallback chain from profile config so oneshot
+ # workers honour the same merge semantics as interactive CLI and
+ # gateway sessions.
+ _fb = get_fallback_chain(cfg)
- agent = AIAgent(
- api_key=runtime.get("api_key"),
- base_url=runtime.get("base_url"),
- provider=runtime.get("provider"),
- api_mode=runtime.get("api_mode"),
- model=effective_model,
- enabled_toolsets=toolsets_list,
- quiet_mode=True,
- platform="cli",
- session_db=session_db,
- credential_pool=runtime.get("credential_pool"),
- fallback_model=_fb or None,
- # Interactive callbacks are intentionally NOT wired beyond this
- # one. In oneshot mode there's no user sitting at a terminal:
- # - clarify → returns a synthetic "pick a default" instruction
- # so the agent continues instead of stalling on
- # the tool's built-in "not available" error
- # - sudo password prompt → terminal_tool gates on
- # HERMES_INTERACTIVE which we never set
- # - shell-hook approval → auto-approved via HERMES_ACCEPT_HOOKS=1
- # (set above); also falls back to deny on non-tty
- # - dangerous-command approval → bypassed via HERMES_YOLO_MODE=1
- # - skill secret capture → returns gracefully when no callback set
- clarify_callback=_oneshot_clarify_callback,
- )
+ agent = AIAgent(
+ api_key=runtime.get("api_key"),
+ base_url=runtime.get("base_url"),
+ provider=runtime.get("provider"),
+ api_mode=runtime.get("api_mode"),
+ model=effective_model,
+ enabled_toolsets=toolsets_list,
+ quiet_mode=True,
+ platform="cli",
+ session_db=session_db,
+ credential_pool=runtime.get("credential_pool"),
+ fallback_model=_fb or None,
+ # Interactive callbacks are intentionally NOT wired beyond this
+ # one. In oneshot mode there's no user sitting at a terminal:
+ # - clarify → returns a synthetic "pick a default" instruction
+ # so the agent continues instead of stalling on
+ # the tool's built-in "not available" error
+ # - sudo password prompt → terminal_tool gates on
+ # HERMES_INTERACTIVE which we never set
+ # - shell-hook approval → auto-approved via HERMES_ACCEPT_HOOKS=1
+ # (set above); also falls back to deny on non-tty
+ # - dangerous-command approval → bypassed via HERMES_YOLO_MODE=1
+ # - skill secret capture → returns gracefully when no callback set
+ clarify_callback=_oneshot_clarify_callback,
+ )
- # Belt-and-braces: make sure AIAgent doesn't invoke any streaming
- # display callbacks that would bypass our stdout capture.
- agent.suppress_status_output = True
- agent.stream_delta_callback = None
- agent.tool_gen_callback = None
+ # Belt-and-braces: make sure AIAgent doesn't invoke any streaming
+ # display callbacks that would bypass our stdout capture.
+ agent.suppress_status_output = True
+ agent.stream_delta_callback = None
+ agent.tool_gen_callback = None
- result = agent.run_conversation(prompt)
- return (result.get("final_response") or "", result)
+ result = agent.run_conversation(prompt)
+ return (result.get("final_response") or "", result)
+ finally:
+ # Ordering deliberately mirrors gateway/run.py:_cleanup_agent_resources,
+ # NOT cli.py:_run_cleanup — oneshot has no _active_agent_ref and must
+ # close the agent explicitly because the hard-exit path skips finalizers.
+ if agent is not None:
+ try:
+ session_messages = getattr(agent, "_session_messages", None)
+ if isinstance(session_messages, list):
+ agent.shutdown_memory_provider(session_messages)
+ else:
+ agent.shutdown_memory_provider()
+ except Exception:
+ logging.debug("oneshot memory/context cleanup failed", exc_info=True)
+ try:
+ agent.close()
+ except Exception:
+ logging.debug("oneshot agent cleanup failed", exc_info=True)
+ # agent.close() calls session_db.end_session() but leaves the connection
+ # open; close it here to checkpoint the WAL before os._exit skips
+ # finalizers.
+ if session_db is not None:
+ try:
+ session_db.close()
+ except Exception:
+ logging.debug("oneshot session store cleanup failed", exc_info=True)
def _oneshot_clarify_callback(question: str, choices=None) -> str:
diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py
index c3e8efd22290..2303e2141158 100644
--- a/hermes_cli/providers.py
+++ b/hermes_cli/providers.py
@@ -765,6 +765,36 @@ def resolve_provider_full(
if user_pdef is not None:
return user_pdef
+ # 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing.
+ # Example: kimi-coding-cn should stay distinct from kimi-coding instead of
+ # normalizing through the shared models.dev alias "kimi-for-coding".
+ # A collapse is lossy only when MULTIPLE distinct registry providers
+ # normalize to the same canonical name — resolving through the alias
+ # would then lose which one the caller meant. Single-entry rewrites
+ # (e.g. "copilot" → "github-copilot") are correct routing and must keep
+ # resolving through the built-in chain below so overlay transports apply.
+ if canonical != raw:
+ try:
+ from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY
+ _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw)
+ if _pcfg is not None:
+ _collapsed_siblings = [
+ _rid
+ for _rid in _AUTH_PROVIDER_REGISTRY
+ if normalize_provider(_rid) == canonical
+ ]
+ if len(_collapsed_siblings) > 1:
+ return ProviderDef(
+ id=_pcfg.id,
+ name=_pcfg.name,
+ transport="openai_chat",
+ api_key_env_vars=tuple(_pcfg.api_key_env_vars or ()),
+ base_url=_pcfg.inference_base_url or "",
+ source="hermes-auth-registry",
+ )
+ except Exception:
+ pass
+
# 1. Built-in (models.dev + overlays)
pdef = get_provider(canonical)
if pdef is not None:
diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py
index fba92e9b350d..6fee9b013a93 100644
--- a/hermes_cli/runtime_provider.py
+++ b/hermes_cli/runtime_provider.py
@@ -659,9 +659,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# First check providers: dict (new-style user-defined providers)
providers = config.get("providers")
if isinstance(providers, dict):
+ from hermes_cli.config import is_provider_enabled
for ep_name, entry in providers.items():
if not isinstance(entry, dict):
continue
+ # Skip providers the user explicitly disabled via
+ # ``providers..enabled: false``. They remain in config
+ # so re-enabling is a one-line edit, but the resolver pretends
+ # they're not configured.
+ if not is_provider_enabled(entry):
+ continue
# Match exact name or normalized name
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env
@@ -1531,6 +1538,27 @@ def resolve_runtime_provider(
"""
requested_provider = resolve_requested_provider(requested)
+ # Honour ``providers..enabled: false`` for BOTH user-defined
+ # custom providers and the built-in ones (openai / anthropic /
+ # openrouter / gemini / ...). The earlier ``_get_named_custom_provider``
+ # gate only covers custom blocks — built-in resolution paths
+ # (``resolve_provider`` + pool / explicit / generic runtime) walk
+ # their own short-circuits and would otherwise return stale config
+ # for a provider the user explicitly turned off.
+ #
+ # Fail fast with a typed error so the fallback chain can advance to
+ # the next provider instead of using a disabled one.
+ from hermes_cli.config import is_provider_enabled, load_config
+ _full_cfg = load_config()
+ _provs_cfg = _full_cfg.get("providers") if isinstance(_full_cfg, dict) else None
+ if isinstance(_provs_cfg, dict):
+ _block = _provs_cfg.get(requested_provider)
+ if isinstance(_block, dict) and not is_provider_enabled(_block):
+ raise ValueError(
+ f"provider {requested_provider!r} is disabled in config "
+ f"(providers.{requested_provider}.enabled: false)"
+ )
+
if requested_provider == "moa":
return {
"provider": "moa",
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index 54f4e5f676d5..0d1a154811b4 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -84,6 +84,7 @@ _DEFAULT_PROVIDER_MODELS = {
"gpt-4o",
"gpt-4o-mini",
"claude-opus-4.6",
+ "claude-sonnet-5",
"claude-sonnet-4.6",
"claude-sonnet-4.5",
"claude-haiku-4.5",
@@ -99,15 +100,15 @@ _DEFAULT_PROVIDER_MODELS = {
"google/gemini-2.5-pro", "google/gemini-2.5-flash",
],
"zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
- "kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
- "kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
+ "kimi-coding": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
+ "kimi-coding-cn": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
- "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
- "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
- "opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
+ "kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
+ "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-5", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
+ "opencode-go": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
"huggingface": [
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
diff --git a/hermes_cli/subcommands/config.py b/hermes_cli/subcommands/config.py
index 8934dfe99e29..8fe1297407f4 100644
--- a/hermes_cli/subcommands/config.py
+++ b/hermes_cli/subcommands/config.py
@@ -40,6 +40,12 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
"key", nargs="?", help="Configuration key (e.g., model, terminal.backend)"
)
config_set.add_argument("value", nargs="?", help="Value to set")
+ config_set.add_argument(
+ "--force",
+ action="store_true",
+ help="Skip the unknown-key notice printed after writing a key the "
+ "running version doesn't recognize (the value is saved either way).",
+ )
# config unset
config_unset = config_subparsers.add_parser(
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 1d4abc7f896e..8995f18471f1 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -779,6 +779,11 @@ _CATEGORY_MERGE: Dict[str, str] = {
# with the other messaging-platform config (discord) so it isn't an
# orphan tab of one field.
"telegram": "discord",
+ # `mcp.auto_reload_on_config_change` is the only schema-surfaced mcp
+ # runtime field (server definitions live under mcp_servers, edited via
+ # the MCP tab) — fold it into the agent tab rather than spawning a
+ # one-field orphan category.
+ "mcp": "agent",
# `computer_use.cua_telemetry` is the only schema-surfaced computer_use
# field — fold it into the agent tab rather than spawning a one-field
# orphan category.
@@ -1045,6 +1050,17 @@ class MemoryProviderSetupRequest(BaseModel):
values: Dict[str, Any] = {}
+class CustomEndpointUpdate(BaseModel):
+ id: str = ""
+ name: str
+ base_url: str
+ model: str
+ api_key: Optional[str] = None
+ context_length: Optional[int] = None
+ discover_models: bool = True
+ make_default: bool = False
+
+
class MessagingPlatformUpdate(BaseModel):
enabled: Optional[bool] = None
env: Dict[str, str] = {}
@@ -1330,7 +1346,12 @@ def _apply_main_model_assignment(
if api_key.strip():
model_cfg["api_key"] = api_key.strip()
model_cfg.pop("api", None)
- elif model_cfg.get("api_key") and new_provider != prev_provider:
+ elif (model_cfg.get("api_key") or model_cfg.get("api")) and new_provider != prev_provider:
+ # A stale endpoint secret can live under the legacy ``api`` alias with
+ # no ``api_key`` (the resolver still reads ``model.api`` as a key), so
+ # the switch-clears-the-key path must trigger on either field — else the
+ # old endpoint's secret survives in config.yaml and contaminates a later
+ # custom resolution. clear_model_endpoint_credentials scrubs both.
clear_model_endpoint_credentials(model_cfg, clear_api_mode=False)
if new_provider != prev_provider:
clear_model_endpoint_credentials(model_cfg, clear_api_key=False)
@@ -6398,9 +6419,24 @@ def _apply_model_assignment_sync(
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
provider, model = _normalize_main_model_assignment(provider, model)
+ providers_cfg = cfg.get("providers")
+ provider_entry = providers_cfg.get(provider) if isinstance(providers_cfg, dict) else None
+ if not base_url and isinstance(provider_entry, dict) and provider_entry.get("base_url"):
+ base_url = str(provider_entry.get("base_url") or "").strip()
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url, api_key
)
+ # Fall back to the provider entry's stored key only when the request
+ # didn't carry one — same precedence as the base_url fill above. An
+ # unconditional overwrite silently discards a key the caller is
+ # rotating in, and model.api_key outranks the environment at client
+ # construction (#62269), so the stale key keeps authenticating.
+ if (
+ not api_key
+ and isinstance(provider_entry, dict)
+ and provider_entry.get("api_key")
+ ):
+ model_cfg["api_key"] = provider_entry["api_key"]
cfg["model"] = model_cfg
# When switching the main provider to Nous, mirror the CLI's
@@ -6928,6 +6964,280 @@ def _parse_model_ids(resp: "Any") -> List[str]:
return ids
+def _custom_endpoint_id(raw: str, fallback: str = "custom") -> str:
+ slug = re.sub(r"[^A-Za-z0-9_-]+", "-", (raw or "").strip()).strip("-_").lower()
+ return slug or fallback
+
+
+def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]:
+ models: List[str] = []
+ raw_models = entry.get("models")
+ if isinstance(raw_models, dict):
+ models.extend(str(model).strip() for model in raw_models.keys())
+ elif isinstance(raw_models, list):
+ models.extend(str(model).strip() for model in raw_models)
+
+ default_model = str(entry.get("model") or entry.get("default_model") or "").strip()
+ if default_model:
+ models.insert(0, default_model)
+
+ seen: set[str] = set()
+ return [model for model in models if model and not (model in seen or seen.add(model))]
+
+
+def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]:
+ model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
+ current_provider = str(model_cfg.get("provider", "") or "")
+ current_model = str(model_cfg.get("default", model_cfg.get("name", "")) or "")
+ current_base_url = str(model_cfg.get("base_url", "") or "")
+
+ endpoints: List[Dict[str, Any]] = []
+ providers = cfg.get("providers")
+ if isinstance(providers, dict):
+ for provider_id, raw_entry in providers.items():
+ if not isinstance(raw_entry, dict):
+ continue
+ base_url = str(raw_entry.get("base_url") or raw_entry.get("url") or raw_entry.get("api") or "").strip()
+ if not base_url:
+ continue
+ endpoint_id = str(provider_id)
+ models = _models_from_custom_endpoint_entry(raw_entry)
+ endpoint_model = str(raw_entry.get("model") or raw_entry.get("default_model") or (models[0] if models else ""))
+ endpoints.append({
+ "id": endpoint_id,
+ "name": str(raw_entry.get("name") or endpoint_id),
+ "base_url": base_url,
+ "model": endpoint_model,
+ "models": models,
+ "context_length": raw_entry.get("context_length"),
+ "discover_models": bool(raw_entry.get("discover_models", True)),
+ "has_api_key": bool(str(raw_entry.get("api_key", "") or "").strip()),
+ "api_key_preview": redact_key(str(raw_entry.get("api_key", "") or "")) if raw_entry.get("api_key") else None,
+ "is_current": endpoint_id == current_provider,
+ "source": "providers",
+ })
+
+ if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints):
+ endpoints.insert(0, {
+ "id": "custom",
+ "name": "Custom",
+ "base_url": current_base_url,
+ "model": current_model,
+ "models": [current_model] if current_model else [],
+ "context_length": model_cfg.get("context_length"),
+ "discover_models": True,
+ "has_api_key": bool(str(model_cfg.get("api_key", "") or "").strip()),
+ "api_key_preview": redact_key(str(model_cfg.get("api_key", "") or "")) if model_cfg.get("api_key") else None,
+ "is_current": True,
+ "source": "direct-config",
+ })
+
+ return {
+ "endpoints": endpoints,
+ "current": {
+ "provider": current_provider,
+ "model": current_model,
+ "base_url": current_base_url,
+ },
+ }
+
+
+def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> None:
+ """Drop the main-slot mirror of a provider that no longer exists.
+
+ ``activate_custom_endpoint`` copies the endpoint's ``base_url`` and
+ ``api_key`` onto ``model``. That mirror outranks the environment at client
+ construction (#62269), so deleting the endpoint without clearing it leaves
+ the agent still authenticating to the deleted host with the deleted key —
+ and leaves that key sitting in config.yaml after the operator believes the
+ dashboard removed it.
+
+ Only touches ``model`` when it actually names the deleted provider, so an
+ endpoint deleted while a *different* provider is active is left alone.
+ """
+ model_cfg = cfg.get("model")
+ if not isinstance(model_cfg, dict):
+ return
+ if str(model_cfg.get("provider") or "").strip().lower() != provider_key:
+ return
+ for field in ("provider", "base_url", "api_key"):
+ model_cfg.pop(field, None)
+ cfg["model"] = model_cfg
+
+
+def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> Tuple[str, Dict[str, Any]]:
+ endpoint_id = _custom_endpoint_id(body.id or body.name)
+ name = (body.name or "").strip()
+ base_url = (body.base_url or "").strip().rstrip("/")
+ model = (body.model or "").strip()
+
+ if not name:
+ raise HTTPException(status_code=400, detail="name required")
+ if not base_url:
+ raise HTTPException(status_code=400, detail="base_url required")
+ parsed = urllib.parse.urlparse(base_url)
+ if not parsed.scheme or not parsed.netloc:
+ raise HTTPException(status_code=400, detail="base_url must include scheme and host")
+ if not model:
+ raise HTTPException(status_code=400, detail="model required")
+
+ providers = cfg.get("providers")
+ if not isinstance(providers, dict):
+ providers = {}
+ existing = providers.get(endpoint_id)
+ if not isinstance(existing, dict):
+ existing = {}
+
+ # Merge onto the existing entry rather than replacing it. A providers.
+ # block is not owned by this panel: it can carry hand-written keys the
+ # dashboard has no field for — ``api_mode``, ``key_env``/``api_key_env``,
+ # ``extra_headers`` (which may themselves carry credentials),
+ # ``request_overrides`` — and rebuilding from scratch silently dropped every
+ # one of them on an unrelated edit, leaving a provider that no longer
+ # authenticates or speaks the right protocol.
+ entry: Dict[str, Any] = dict(existing)
+ entry.update({
+ "name": name,
+ "base_url": base_url,
+ "model": model,
+ "discover_models": bool(body.discover_models),
+ })
+ # Same for the model map: the panel names one default model, it does not
+ # enumerate the provider's catalogue. Keep the other models (and their
+ # context lengths) and just ensure this one is present.
+ existing_models = entry.get("models")
+ models_map: Dict[str, Any] = dict(existing_models) if isinstance(existing_models, dict) else {}
+ current_model_entry = models_map.get(model)
+ models_map[model] = dict(current_model_entry) if isinstance(current_model_entry, dict) else {}
+ entry["models"] = models_map
+ if body.context_length and body.context_length > 0:
+ entry["context_length"] = int(body.context_length)
+ entry["models"][model]["context_length"] = int(body.context_length)
+ if body.api_key is not None and body.api_key.strip():
+ entry["api_key"] = body.api_key.strip()
+
+ providers[endpoint_id] = entry
+ cfg["providers"] = providers
+
+ if body.make_default:
+ cfg["model"] = _apply_main_model_assignment(
+ cfg.get("model", {}), endpoint_id, model, base_url
+ )
+ if entry.get("api_key") and isinstance(cfg["model"], dict):
+ cfg["model"]["api_key"] = entry["api_key"]
+
+ return endpoint_id, entry
+
+
+@app.get("/api/providers/custom-endpoints")
+def list_custom_endpoints():
+ """Return configured OpenAI-compatible custom endpoints for Desktop."""
+ try:
+ return _custom_endpoint_response(load_config())
+ except Exception:
+ _log.exception("GET /api/providers/custom-endpoints failed")
+ raise HTTPException(status_code=500, detail="Failed to list custom endpoints")
+
+
+@app.post("/api/providers/custom-endpoints")
+def upsert_custom_endpoint(body: CustomEndpointUpdate):
+ """Create or update a v12+ ``providers`` custom endpoint entry."""
+ try:
+ cfg = load_config()
+ endpoint_id, _entry = _write_custom_endpoint(cfg, body)
+ save_config(cfg)
+ response = _custom_endpoint_response(cfg)
+ response["ok"] = True
+ response["id"] = endpoint_id
+ return response
+ except HTTPException:
+ raise
+ except Exception:
+ _log.exception("POST /api/providers/custom-endpoints failed")
+ raise HTTPException(status_code=500, detail="Failed to save custom endpoint")
+
+
+@app.post("/api/providers/custom-endpoints/{endpoint_id}/activate")
+def activate_custom_endpoint(endpoint_id: str):
+ """Set a configured custom endpoint as the default model provider."""
+ try:
+ cfg = load_config()
+ provider_key = _custom_endpoint_id(endpoint_id)
+ providers = cfg.get("providers")
+ entry = providers.get(provider_key) if isinstance(providers, dict) else None
+ if not isinstance(entry, dict):
+ raise HTTPException(status_code=404, detail="custom endpoint not found")
+
+ models = _models_from_custom_endpoint_entry(entry)
+ model = str(entry.get("model") or (models[0] if models else "")).strip()
+ base_url = str(entry.get("base_url") or "").strip()
+ if not model or not base_url:
+ raise HTTPException(status_code=400, detail="custom endpoint is incomplete")
+
+ model_cfg = _apply_main_model_assignment(cfg.get("model", {}), provider_key, model, base_url)
+ if entry.get("api_key"):
+ model_cfg["api_key"] = entry["api_key"]
+ cfg["model"] = model_cfg
+ save_config(cfg)
+ return {"ok": True, "provider": provider_key, "model": model}
+ except HTTPException:
+ raise
+ except Exception:
+ _log.exception("POST /api/providers/custom-endpoints/%s/activate failed", endpoint_id)
+ raise HTTPException(status_code=500, detail="Failed to activate custom endpoint")
+
+
+@app.delete("/api/providers/custom-endpoints/{endpoint_id}")
+def delete_custom_endpoint(endpoint_id: str):
+ """Remove a configured custom endpoint from ``providers``."""
+ try:
+ cfg = load_config()
+ provider_key = _custom_endpoint_id(endpoint_id)
+ providers = cfg.get("providers")
+ if not isinstance(providers, dict) or provider_key not in providers:
+ raise HTTPException(status_code=404, detail="custom endpoint not found")
+ providers.pop(provider_key, None)
+ cfg["providers"] = providers
+ _detach_main_model_from_provider(cfg, provider_key)
+ save_config(cfg)
+ response = _custom_endpoint_response(cfg)
+ response["ok"] = True
+ return response
+ except HTTPException:
+ raise
+ except Exception:
+ _log.exception("DELETE /api/providers/custom-endpoints/%s failed", endpoint_id)
+ raise HTTPException(status_code=500, detail="Failed to delete custom endpoint")
+
+
+@app.post("/api/providers/custom-endpoints/validate")
+async def validate_custom_endpoint(body: CustomEndpointUpdate):
+ """Probe a custom endpoint by calling its OpenAI-compatible /models URL."""
+ import httpx
+
+ base_url = (body.base_url or "").strip().rstrip("/")
+ if not base_url:
+ return {"ok": False, "reachable": True, "message": "Enter an endpoint URL first.", "models": []}
+
+ url = base_url + "/models"
+ headers = {"Accept": "application/json"}
+ if body.api_key and body.api_key.strip():
+ headers["Authorization"] = f"Bearer {body.api_key.strip()}"
+
+ try:
+ with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
+ resp = client.get(url, headers=headers)
+ except Exception:
+ return {"ok": False, "reachable": False, "message": f"Could not reach {url}.", "models": []}
+
+ if resp.status_code in (401, 403):
+ return {"ok": False, "reachable": True, "message": "The endpoint rejected the API key.", "models": []}
+ if not resp.is_success:
+ return {"ok": False, "reachable": True, "message": f"Endpoint returned HTTP {resp.status_code}.", "models": []}
+
+ return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
+
+
@app.post("/api/providers/validate")
async def validate_provider_credential(body: EnvVarUpdate, request: Request):
"""Live-probe a provider credential before it's saved.
@@ -11096,11 +11406,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
return _fallback_profile_dicts(profiles_mod)
+def _cron_default_profile() -> str:
+ """Profile to target when a cron request carries no explicit ``profile``.
+
+ A desktop pool backend runs one process per profile (HERMES_HOME already
+ scoped), but these cron endpoints deliberately route storage through the
+ profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"``
+ fallback would write a non-default profile's job into ``~/.hermes``.
+ Resolve the process's own profile instead. ``custom`` (an unrecognized
+ HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so
+ it keeps the legacy ``default`` fallback.
+ """
+ try:
+ from hermes_cli.profiles import get_active_profile_name
+
+ name = get_active_profile_name()
+ except Exception:
+ return "default"
+ return "default" if name in ("default", "custom") else name
+
+
def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
from hermes_cli import profiles as profiles_mod
- raw = (profile or "default").strip() or "default"
+ raw = (profile or _cron_default_profile()).strip() or "default"
try:
canon = profiles_mod.normalize_profile_name(raw)
profiles_mod.validate_profile_name(canon)
@@ -11256,7 +11586,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)
-def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
+def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None):
try:
profile_name, profile_home = _cron_profile_home(profile)
script = _normalize_dashboard_cron_script(body.script, profile_home)
@@ -11295,7 +11625,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
@app.post("/api/cron/jobs")
-async def create_cron_job(body: CronJobCreate, profile: str = "default"):
+async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile)
@@ -14732,6 +15062,12 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
# the GUI can offer per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
+ if name == "tts" and prov.get("tts_provider"):
+ # The provider key written to tts.provider on selection.
+ # Doubles as the config section holding the provider's
+ # voice/model settings (tts..*) so the GUI can render
+ # those fields inline in the Capabilities panel.
+ row["tts_provider"] = prov["tts_provider"]
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the
diff --git a/hermes_state.py b/hermes_state.py
index 398b1442d6f7..8d8ff6bc0213 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -5066,6 +5066,47 @@ class SessionDB:
)
return model_history, display_history
+ def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
+ """Return the ancestor-only display messages for a session lineage.
+
+ These are messages from parent/grandparent sessions (compression
+ ancestors) that appear in the display transcript but NOT in the
+ tip session's model-fed history. Used by ``session.resume`` to
+ build the ``display_history_prefix`` that ``_live_session_payload``
+ prepends to the live model history.
+
+ Previously the prefix was calculated as
+ ``display_history[:len(display) - len(raw)]``, but that overcounts
+ when ``repair_message_sequence`` removes messages from the MIDDLE
+ of the tip history (e.g. verification candidates collapsed by the
+ consecutive-assistant merge) — the length difference includes both
+ ancestor messages AND repair-removed tip messages, but the slice
+ only captures the first N display messages (which are tip messages
+ when there are no ancestors), causing duplication. This method
+ returns ONLY the genuine ancestor messages, identified by
+ ``session_id != tip_session_id``. (#65919)
+ """
+ session_ids = self._session_lineage_root_to_tip(session_id)
+ if len(session_ids) <= 1:
+ return []
+ with self._lock:
+ placeholders = ",".join("?" for _ in session_ids)
+ rows = self._conn.execute(
+ f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
+ f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
+ "ORDER BY id",
+ tuple(session_ids),
+ ).fetchall()
+ ancestor_rows = [r for r in rows if r["session_id"] != session_id]
+ if not ancestor_rows:
+ return []
+ return self._rows_to_conversation(
+ ancestor_rows,
+ session_id=session_id,
+ include_ancestors=True,
+ repair_alternation=False,
+ )
+
def get_conversation_root(self, session_id: str) -> str:
"""Return the ROOT id of *session_id*'s lineage chain.
diff --git a/nix/tui.nix b/nix/tui.nix
index 80696a0626a3..c61c6fedf1e5 100644
--- a/nix/tui.nix
+++ b/nix/tui.nix
@@ -1,7 +1,12 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
- npm = hermesNpmLib.mkNpmPassthru { dirs = [ "ui-tui" ]; };
+ npm = hermesNpmLib.mkNpmPassthru {
+ dirs = [
+ "ui-tui"
+ "apps/shared"
+ ];
+ };
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md
index 18fffcd7db1f..3636905d2edf 100644
--- a/plugins/memory/supermemory/README.md
+++ b/plugins/memory/supermemory/README.md
@@ -5,7 +5,8 @@ Semantic long-term memory with profile recall, semantic search, explicit memory
## Requirements
- `pip install supermemory`
-- Supermemory API key from [app.supermemory.ai/integrations?connect=hermes](http://app.supermemory.ai/integrations?connect=hermes)
+- Hosted: API key from [app.supermemory.ai/integrations?connect=hermes](http://app.supermemory.ai/integrations?connect=hermes)
+- Self-hosted: a running [Supermemory local](https://supermemory.ai/docs/self-hosting/overview) server and the API key it prints on first boot
## Setup
@@ -20,12 +21,32 @@ hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
```
+For a fully self-hosted setup, start Supermemory local and note the API key it
+prints on first boot:
+
+```bash
+npx supermemory local
+```
+
+Before running `hermes memory setup`, add the local endpoint to
+`$HERMES_HOME/supermemory.json`:
+
+```json
+{
+ "base_url": "http://localhost:6767"
+}
+```
+
+Then run `hermes memory setup` and enter the local server's API key. Configuring
+the endpoint first ensures the setup connection probe also stays local.
+
## Config
Config file: `$HERMES_HOME/supermemory.json`
| Key | Default | Description |
|-----|---------|-------------|
+| `base_url` | `https://api.supermemory.ai` | API endpoint for hosted or self-hosted Supermemory. Takes priority over `SUPERMEMORY_BASE_URL`. |
| `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags (e.g. `hermes-{identity}` → `hermes-coder`). |
| `auto_recall` | `true` | Inject relevant memory context before turns |
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
@@ -41,8 +62,13 @@ Config file: `$HERMES_HOME/supermemory.json`
| Variable | Description |
|----------|-------------|
| `SUPERMEMORY_API_KEY` | API key (required) |
+| `SUPERMEMORY_BASE_URL` | Compatibility fallback for the API endpoint when `base_url` is not configured |
| `SUPERMEMORY_CONTAINER_TAG` | Override container tag (takes priority over config file) |
+Base URL precedence is `supermemory.json` → `SUPERMEMORY_BASE_URL` →
+`https://api.supermemory.ai`. Hermes resolves it once and uses the same endpoint
+for SDK operations, setup/status probes, and full-session conversation ingest.
+
## Tools
Kebab-case names are registered for the agent; snake_case aliases remain supported.
@@ -69,6 +95,7 @@ When enabled, Hermes can:
- prefetch relevant memory context before each turn
- buffer the full conversation and ingest it as **one session** at session end (or on `/reset`, branch, compression, or shutdown)
- ingest the full session to the conversations endpoint for richer profile/graph updates
+- route every SDK, probe, and conversation-ingest request through the configured hosted or self-hosted endpoint
- expose explicit tools for search, store, forget, and profile access
The session is written once via the conversations endpoint, which drives Supermemory's entity extraction and profile building while keeping a clean, retrievable full transcript.
diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py
index 1d086f47df84..2112b5095d76 100644
--- a/plugins/memory/supermemory/__init__.py
+++ b/plugins/memory/supermemory/__init__.py
@@ -31,7 +31,7 @@ _VALID_SEARCH_MODES = ("hybrid", "memories", "documents")
_DEFAULT_API_TIMEOUT = 5.0
_MIN_CAPTURE_LENGTH = 10
_MAX_ENTITY_CONTEXT_LENGTH = 1500
-_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations"
+_DEFAULT_BASE_URL = "https://api.supermemory.ai"
_API_KEY_URL = "http://app.supermemory.ai/integrations?connect=hermes"
_TRIVIAL_RE = re.compile(
r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$",
@@ -65,6 +65,7 @@ def _default_config() -> dict:
"search_mode": _DEFAULT_SEARCH_MODE,
"entity_context": _DEFAULT_ENTITY_CONTEXT,
"api_timeout": _DEFAULT_API_TIMEOUT,
+ "base_url": "",
"enable_custom_container_tags": False,
"custom_containers": [],
"custom_container_instructions": "",
@@ -77,6 +78,18 @@ def _sanitize_tag(raw: str) -> str:
return tag.strip("_") or _DEFAULT_CONTAINER_TAG
+def _resolve_base_url(config_value: Any = "") -> str:
+ """Resolve the API base URL: config > SUPERMEMORY_BASE_URL env var > default.
+
+ Supports self-hosted Supermemory servers (e.g. http://localhost:6767).
+ """
+ raw = (
+ str(config_value or "").strip()
+ or os.environ.get("SUPERMEMORY_BASE_URL", "").strip()
+ )
+ return (raw or _DEFAULT_BASE_URL).rstrip("/") or _DEFAULT_BASE_URL
+
+
def _clamp_entity_context(text: str) -> str:
if not text:
return _DEFAULT_ENTITY_CONTEXT
@@ -129,6 +142,7 @@ def _load_supermemory_config(hermes_home: str) -> dict:
config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT))))
except Exception:
config["api_timeout"] = _DEFAULT_API_TIMEOUT
+ config["base_url"] = str(config.get("base_url", "") or "").strip()
# Multi-container support
config["enable_custom_container_tags"] = _as_bool(config.get("enable_custom_container_tags"), False)
@@ -263,7 +277,8 @@ def _is_trivial_message(text: str) -> bool:
class _SupermemoryClient:
- def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
+ def __init__(self, api_key: str, timeout: float, container_tag: str,
+ search_mode: str = "hybrid", base_url: str = ""):
# Lazy-install the supermemory SDK on demand. ensure() honors
# security.allow_lazy_installs (default true) and, on a sealed Docker
# venv, redirects the install to the durable target. On failure we
@@ -276,15 +291,16 @@ class _SupermemoryClient:
pass
except Exception:
pass
-
from supermemory import Supermemory
self._api_key = api_key
self._container_tag = container_tag
self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
self._timeout = timeout
+ self._base_url = _resolve_base_url(base_url)
self._client = Supermemory(
api_key=api_key,
+ base_url=self._base_url,
timeout=timeout,
max_retries=0,
default_headers={"x-sm-source": "hermes"},
@@ -388,7 +404,7 @@ class _SupermemoryClient:
payload["metadata"] = self._merge_metadata(metadata)
req = urllib.request.Request(
- _CONVERSATIONS_URL,
+ f"{self._base_url}/v4/conversations",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {self._api_key}",
@@ -410,6 +426,7 @@ def _resolve_container_tag_for_setup(hermes_home: str, *, identity: str = "defau
def _probe_supermemory_connection(api_key: str, hermes_home: str, *, identity: str = "default") -> dict:
config = _load_supermemory_config(hermes_home)
+ base_url = _resolve_base_url(config["base_url"])
status = {
"ok": False,
"error": "",
@@ -432,6 +449,7 @@ def _probe_supermemory_connection(api_key: str, hermes_home: str, *, identity: s
timeout=config["api_timeout"],
container_tag=status["container_tag"],
search_mode=config["search_mode"],
+ base_url=base_url,
)
profile = client.get_profile()
facts = [
@@ -531,6 +549,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
self._search_mode = _DEFAULT_SEARCH_MODE
self._entity_context = _DEFAULT_ENTITY_CONTEXT
self._api_timeout = _DEFAULT_API_TIMEOUT
+ self._base_url = _DEFAULT_BASE_URL
self._hermes_home = ""
self._write_enabled = True
self._active = False
@@ -645,6 +664,9 @@ class SupermemoryMemoryProvider(MemoryProvider):
self._search_mode = self._config["search_mode"]
self._entity_context = self._config["entity_context"]
self._api_timeout = self._config["api_timeout"]
+ # Base URL: config > SUPERMEMORY_BASE_URL env var > api.supermemory.ai.
+ # Supports self-hosted Supermemory servers.
+ self._base_url = _resolve_base_url(self._config["base_url"])
self._enable_custom_containers = self._config["enable_custom_container_tags"]
self._custom_containers = self._config["custom_containers"]
self._custom_container_instructions = self._config["custom_container_instructions"]
@@ -663,6 +685,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
timeout=self._api_timeout,
container_tag=self._container_tag,
search_mode=self._search_mode,
+ base_url=self._base_url,
)
except Exception:
logger.warning("Supermemory initialization failed", exc_info=True)
diff --git a/plugins/model-providers/gmi/__init__.py b/plugins/model-providers/gmi/__init__.py
index fb0220708038..bf7dfb91689e 100644
--- a/plugins/model-providers/gmi/__init__.py
+++ b/plugins/model-providers/gmi/__init__.py
@@ -23,6 +23,7 @@ gmi = ProviderProfile(
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"google/gemini-3.1-flash-lite-preview",
+ "anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
),
diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py
index 8017589e350b..69aa3591bb61 100644
--- a/plugins/platforms/dingtalk/adapter.py
+++ b/plugins/platforms/dingtalk/adapter.py
@@ -1660,6 +1660,31 @@ def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None:
ac = ",".join(str(v) for v in ac)
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
allowed = dingtalk_cfg.get("allowed_users")
+ if allowed is None:
+ # Fall back to the documented nested paths (#44928). The docs
+ # (website/docs/user-guide/messaging/dingtalk.md) configure the
+ # allowlist at gateway.platforms.dingtalk.extra.allowed_users; the
+ # adapter reads it from PlatformConfig.extra, but gateway
+ # authorization (_is_user_authorized in gateway/authz_mixin.py)
+ # only consults DINGTALK_ALLOWED_USERS — without this bridge a
+ # nested-only allowlist passes the adapter and is then denied at
+ # the gateway. Check this block's own extra first (the dispatch
+ # loop passes the platforms block here when no top-level
+ # ``dingtalk:`` section exists), then both nested containers.
+ _extra = dingtalk_cfg.get("extra")
+ if isinstance(_extra, dict):
+ allowed = _extra.get("allowed_users")
+ if allowed is None:
+ _gw = yaml_cfg.get("gateway")
+ _gw_platforms = _gw.get("platforms") if isinstance(_gw, dict) else None
+ for _container in (_gw_platforms, yaml_cfg.get("platforms")):
+ if not isinstance(_container, dict):
+ continue
+ _dt = _container.get("dingtalk")
+ _dt_extra = _dt.get("extra") if isinstance(_dt, dict) else None
+ if isinstance(_dt_extra, dict) and _dt_extra.get("allowed_users") is not None:
+ allowed = _dt_extra.get("allowed_users")
+ break
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py
index 96528646939d..4668a7372750 100644
--- a/plugins/platforms/feishu/adapter.py
+++ b/plugins/platforms/feishu/adapter.py
@@ -152,11 +152,24 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
_MARKDOWN_HINT_RE = re.compile(
- r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
+ # Pipe table: any header line + separator line both starting with '|'.
+ r"(^\|.*\|\s*\n\|[-:|\s]+\|)"
+ # Headings, lists, code, bold/italic/strike/underline, links, blockquotes.
+ r"|(^#{1,6}\s)"
+ r"|(^\s*[-*]\s)"
+ r"|(^\s*\d+\.\s)"
+ r"|(^\s*---+\s*$)"
+ r"|(```)"
+ r"|(`[^`\n]+`)"
+ r"|(\*\*[^*\n].+?\*\*)"
+ r"|(~~[^~\n].+?~~)"
+ r"|(.+?)"
+ r"|(\*[^*\n]+\*)"
+ r"|(\[[^\]]+\]\([^)]+\))"
+ r"|(^>\s)",
re.MULTILINE,
)
-# Detect markdown tables: a line starting with | followed by a separator line.
-# Feishu post-type 'md' elements do not render tables, so we force text mode.
+# Backwards-compatible alias retained because external callers reference it.
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
@@ -1897,11 +1910,21 @@ class FeishuAdapter(BasePlatformAdapter):
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
+ # When chunking splits a long markdown response, an individual chunk
+ # can end up as plain prose that doesn't match the per-chunk hint
+ # regex — so it would be sent as ``msg_type=text`` and the user would
+ # see literal ``**bold``/``## heading``/code fences in the Feishu
+ # client while other chunks render correctly. Lock the markdown
+ # decision at the whole-message level so every chunk consistently
+ # uses ``post``. See #26841.
+ prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))
last_response = None
try:
for chunk in chunks:
- msg_type, payload = self._build_outbound_payload(chunk)
+ msg_type, payload = self._build_outbound_payload(
+ chunk, prefer_post=prefer_post,
+ )
try:
response = await self._feishu_send_with_retry(
chat_id=chat_id,
@@ -4532,14 +4555,21 @@ class FeishuAdapter(BasePlatformAdapter):
# Outbound payload construction and send pipeline
# =========================================================================
- def _build_outbound_payload(self, content: str) -> tuple[str, str]:
- # Feishu post-type 'md' elements do not render markdown tables; sending
- # table content as post causes the message to appear blank on the client.
- # Force plain text for anything that looks like a markdown table.
- if _MARKDOWN_TABLE_RE.search(content):
- text_payload = {"text": content}
- return "text", json.dumps(text_payload, ensure_ascii=False)
- if _MARKDOWN_HINT_RE.search(content):
+ def _build_outbound_payload(
+ self, content: str, *, prefer_post: bool = False,
+ ) -> tuple[str, str]:
+ # Empirically (issue #52786), current Feishu clients render markdown
+ # tables inside ``post``-type ``md`` elements natively. The previous
+ # table-downgrade branch forced any table-containing message to
+ # ``text``, which left Feishu readers seeing the raw pipe-and-dash
+ # source instead of a rendered table. Trust the common markdown path
+ # for table content too.
+ #
+ # ``prefer_post`` lets ``send`` treat the chunk as part of a larger
+ # markdown document: when a long markdown reply is split at
+ # MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise
+ # mis-classify a plain-prose chunk as ``text``. See #26841.
+ if prefer_post or _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
return "text", json.dumps(text_payload, ensure_ascii=False)
diff --git a/run_agent.py b/run_agent.py
index 90251026ebdc..c261908dc3ad 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -229,11 +229,12 @@ _EPHEMERAL_SCAFFOLDING_FLAGS = (
"_empty_recovery_synthetic",
"_empty_terminal_sentinel",
"_thinking_prefill",
- # verify-on-stop and pre_verify nudges append a synthetic assistant
- # "done" plus a synthetic user nudge to keep the agent going one more
- # turn before it can claim completion. Those messages exist only to
- # drive the verification loop; persisting them poisons the resumed
- # transcript and breaks prompt-prefix cache reuse on later turns. (#55733)
+ # verify-on-stop and pre_verify nudges append a synthetic user nudge to
+ # keep the agent going one more turn before it can claim completion.
+ # The nudge exists only to drive the verification loop; persisting it
+ # poisons the resumed transcript and breaks prompt-prefix cache reuse
+ # on later turns. The assistant candidate is NOT synthetic — it is
+ # persisted and emitted as an interim message (#65919).
"_verification_stop_synthetic",
"_pre_verify_synthetic",
# kanban worker stop-guard: narrated exit without kanban_complete/block
@@ -4913,7 +4914,16 @@ class AIAgent:
streamed = self._normalize_interim_visible_text(
self._strip_think_blocks(getattr(self, "_current_streamed_assistant_text", "") or "")
)
- return bool(streamed) and streamed == visible_content
+ # Prefix match (not exact equality): the final response may be the
+ # streamed text plus a trailing delta, or the stream may have been
+ # partial when the verify nudge fired. In both cases the streamed
+ # content is a prefix of the final — that's enough to mark it
+ # previewed (fails safe to a benign duplicate, never loses text).
+ # The reverse direction (streamed longer than final) is NOT matched:
+ # that could suppress a needed resend in the gateway path where
+ # already_streamed=True calls on_segment_break() instead of
+ # on_commentary() (#65919 review).
+ return bool(streamed) and visible_content.startswith(streamed)
def _extract_codex_interim_visible_parts(
self,
@@ -5018,8 +5028,19 @@ class AIAgent:
except Exception:
logger.debug("interim_assistant_callback error", exc_info=True)
- def _emit_interim_assistant_message(self, assistant_msg: Dict[str, Any]) -> None:
- """Surface a real mid-turn assistant commentary message to the UI layer."""
+ def _emit_interim_assistant_message(
+ self, assistant_msg: Dict[str, Any]
+ ) -> None:
+ """Surface a real mid-turn assistant commentary message to the UI layer.
+
+ Does NOT set ``_response_was_previewed`` — that flag means "the final
+ response was already shown to the user," but this helper is called for
+ ordinary tool-call narration, intermediate acknowledgements, and
+ verification candidates alike. Setting it here would cause the CLI to
+ suppress a *different* final summary (e.g. from ``_handle_max_iterations``)
+ when the only streamed text was unrelated mid-turn commentary. (#65919
+ review: response-loss blocker)
+ """
cb = getattr(self, "interim_assistant_callback", None)
if cb is None or not isinstance(assistant_msg, dict):
return
diff --git a/scripts/check_subprocess_stdin.py b/scripts/check_subprocess_stdin.py
index 4d312a5949e6..eca28b814ee5 100644
--- a/scripts/check_subprocess_stdin.py
+++ b/scripts/check_subprocess_stdin.py
@@ -37,6 +37,27 @@ TUI_CONTEXT_DIRS = [
"tui_gateway/",
]
+# User plugin roots — scanned at runtime if they exist. Plugins load from
+# ``get_hermes_home() / "plugins"`` (user) and ``./.hermes/plugins/`` (project,
+# gated behind ``HERMES_ENABLE_PROJECT_PLUGINS``) — see
+# ``hermes_cli/plugins.py:10-12``. The guard only checked the bundled
+# ``plugins/`` dir, missing user-installed code that spawns subprocesses
+# (gap reported in #67639).
+#
+# Import is deferred to ``main()`` (after ``os.chdir(repo_root)``) because
+# this script runs as a standalone subprocess — ``hermes_constants`` isn't
+# on ``sys.path`` until the repo root is added.
+
+# subprocess and os APIs that inherit stdin by default when called without
+# an explicit stdin= argument. The original regex only covered run/Popen
+# (gap #1 in #67639); call, check_output, check_call, os.system, and
+# asyncio.create_subprocess_* all inherit fd 0 equally.
+_SUBPROCESS_PATTERNS = [
+ r"subprocess\.(run|Popen|call|check_output|check_call)\s*\([\"'a-zA-Z_\[\(]",
+ r"os\.system\s*\([\"'a-zA-Z_\[\(]",
+ r"asyncio\.create_subprocess_(exec|shell)\s*\([\"'a-zA-Z_\[\(]",
+]
+
# Files with intentional stdin= override (e.g. input= creates a pipe).
# Format: "filepath:line" or just "filepath" to skip the whole file.
KNOWN_SAFE = {
@@ -64,16 +85,14 @@ SKIP_DIRS = {
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
- """Find all subprocess.run/Popen calls missing stdin= in content."""
+ """Find all subprocess/os/asyncio calls missing stdin= in content."""
violations = []
lines = content.split("\n")
# Match only actual function calls — not comments, docstrings, or prose.
- # The pattern requires an opening paren followed by an arg character
- # (quote, bracket, letter, or closing paren for empty calls).
- # This excludes ``subprocess.Popen(...)`` in docstrings and
- # subprocess.run(...) in comments.
- pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
+ # Multiple patterns cover subprocess.run/Popen/call/check_output/check_call,
+ # os.system, and asyncio.create_subprocess_exec/shell.
+ patterns = [re.compile(p) for p in _SUBPROCESS_PATTERNS]
for i, line in enumerate(lines):
# Skip comments.
@@ -85,7 +104,7 @@ def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
if "``subprocess" in line:
continue
- if not pattern.search(line):
+ if not any(p.search(line) for p in patterns):
continue
# Collect the full call (may span multiple lines).
@@ -138,6 +157,11 @@ def main() -> int:
repo_root = Path(__file__).resolve().parent.parent
os.chdir(repo_root)
+ # Add repo root to sys.path so we can import hermes_constants (this script
+ # runs as a standalone subprocess, not as a module).
+ sys.path.insert(0, str(repo_root))
+ from hermes_constants import get_hermes_home
+
all_violations = []
for tui_dir in TUI_CONTEXT_DIRS:
@@ -161,6 +185,32 @@ def main() -> int:
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
+ # Scan user plugin directories (Gap 1: guard missed user-installed
+ # plugins in get_hermes_home()/plugins/ and project plugins in
+ # ./.hermes/plugins/, where code like ori/hooks.py can spawn
+ # subprocesses with inherited stdin — #67639).
+ plugin_roots: list[Path] = [get_hermes_home() / "plugins"]
+ if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
+ plugin_roots.append(Path.cwd() / ".hermes" / "plugins")
+ seen_roots: set[Path] = set()
+ for plugin_root in plugin_roots:
+ resolved = plugin_root.resolve()
+ if resolved in seen_roots or not resolved.is_dir():
+ continue
+ seen_roots.add(resolved)
+
+ for py_file in resolved.rglob("*.py"):
+ rel = str(py_file)
+ if py_file.name in ("conftest.py",) or "/tests/" in rel:
+ continue
+
+ try:
+ content = py_file.read_text()
+ except Exception:
+ continue
+ violations = find_subprocess_calls(content, rel)
+ all_violations.extend(violations)
+
if all_violations:
print(f"❌ {len(all_violations)} subprocess calls missing stdin=:")
for v in all_violations:
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 746b5090faaf..0a98ad6e457b 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -1686,11 +1686,54 @@ function Install-Repository {
Move-Item $extractedDir.FullName $InstallDir -Force
Write-Success "Downloaded and extracted"
- # Initialize git repo so updates work later
+ # Initialize git repo so updates work later. A bare
+ # `git init` leaves NO HEAD -- desktop's write-build-stamp
+ # then hard-fails with "could not determine git commit"
+ # (#50823 / #61657). Fetch the requested ref and force-check
+ # it out (-f) so untracked ZIP files cannot block checkout.
Push-Location $InstallDir
git -c windows.appendAtomically=false init 2>$null
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
+ # Pin autocrlf=false BEFORE the checkout below. Git for Windows
+ # defaults to core.autocrlf=true, which would renormalize the
+ # repo's LF text files to CRLF in the working tree during
+ # `checkout -f FETCH_HEAD` -- leaving this freshly-created
+ # managed checkout dirty vs HEAD and aborting the next
+ # `hermes update` (see the notes at the shared clone-path
+ # config below and install.ps1:1461-1469). The later pin on
+ # the shared path is idempotent and still covers git clones.
+ git -c windows.appendAtomically=false config core.autocrlf false 2>$null
git remote add origin $RepoUrlHttps 2>$null
+ $fetchRef = if ($Commit) { $Commit } elseif ($Tag) { "refs/tags/$Tag" } else { $Branch }
+ Write-Info "Fetching $fetchRef so the ZIP checkout has a resolvable HEAD..."
+ $prevZipEAP = $ErrorActionPreference
+ $ErrorActionPreference = "Continue"
+ try {
+ git -c windows.appendAtomically=false fetch --depth 1 origin $fetchRef 2>&1 | Out-Null
+ if ($LASTEXITCODE -eq 0) {
+ if ($Commit -or $Tag) {
+ git -c windows.appendAtomically=false checkout -f --detach FETCH_HEAD 2>&1 | Out-Null
+ } else {
+ git -c windows.appendAtomically=false checkout -f -B $Branch FETCH_HEAD 2>&1 | Out-Null
+ }
+ if ($LASTEXITCODE -eq 0) {
+ Write-Success "ZIP checkout pinned to $fetchRef"
+ } else {
+ # Checkout blocked, but FETCH_HEAD still has a SHA we can stamp with.
+ $fetchSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
+ if ($LASTEXITCODE -eq 0 -and $fetchSha) {
+ if (-not $env:GITHUB_SHA) { $env:GITHUB_SHA = ("$fetchSha").Trim() }
+ Write-Warn "ZIP checkout failed; seeded GITHUB_SHA from FETCH_HEAD for desktop stamp"
+ } else {
+ Write-Warn "ZIP extract succeeded but git checkout failed -- desktop build may need `$env:GITHUB_SHA"
+ }
+ }
+ } else {
+ Write-Warn "ZIP extract succeeded but git fetch of $fetchRef failed -- desktop build may need `$env:GITHUB_SHA"
+ }
+ } finally {
+ $ErrorActionPreference = $prevZipEAP
+ }
Pop-Location
Write-Success "Git repo initialized for future updates"
@@ -2888,6 +2931,41 @@ function Install-Desktop {
# for some other tool, electron-builder would still try to sign.
Write-Info "Building desktop app (this takes 1-3 minutes)..."
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
+ # Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars
+ # over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH
+ # even though this PowerShell session can, (2) ZIP/init trees that still
+ # lack a HEAD after a failed post-extract fetch. Without it the desktop
+ # pack dies with "could not determine git commit" (#50823).
+ if (-not $env:GITHUB_SHA) {
+ if ($Commit) {
+ $env:GITHUB_SHA = $Commit
+ } else {
+ Push-Location $InstallDir
+ try {
+ $global:LASTEXITCODE = 0
+ $resolvedSha = & git -c windows.appendAtomically=false rev-parse HEAD 2>$null
+ if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) {
+ # ZIP path may have FETCH_HEAD after a fetch even when HEAD is unset.
+ $global:LASTEXITCODE = 0
+ $resolvedSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
+ }
+ if ($LASTEXITCODE -eq 0 -and $resolvedSha) {
+ $env:GITHUB_SHA = ("$resolvedSha").Trim()
+ }
+ } catch { } finally {
+ Pop-Location
+ }
+ }
+ }
+ if (-not $env:GITHUB_REF_NAME) {
+ $env:GITHUB_REF_NAME = if ($Branch) { $Branch } else { "main" }
+ }
+ if ($env:GITHUB_SHA) {
+ $shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA }
+ Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))"
+ } else {
+ Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback"
+ }
Push-Location $desktopDir
$prevEAP = $ErrorActionPreference
$prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY
diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py
index 1519c41c81ae..982defc00a38 100644
--- a/tests/agent/test_anthropic_adapter.py
+++ b/tests/agent/test_anthropic_adapter.py
@@ -1603,18 +1603,50 @@ class TestBuildAnthropicKwargs:
assert _forbids_sampling_params(m) is False, m
def test_non_claude_anthropic_models_use_manual_path(self):
- """Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not
- be misclassified as adaptive by the default-to-modern rule."""
+ """Non-Claude Anthropic-Messages models (minimax, qwen3, glm) must not
+ be misclassified as adaptive by the default-to-modern rule. Kimi is
+ the deliberate exception — see test_kimi_family_uses_adaptive_path."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
- for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"):
+ for m in ("minimax-m2", "qwen3-max", "glm-4.6"):
assert _supports_adaptive_thinking(m) is False, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
+ def test_kimi_family_uses_adaptive_path(self):
+ """Kimi / Moonshot models use adaptive thinking: their
+ Anthropic-compatible endpoints accept thinking.type="adaptive" +
+ output_config.effort including xhigh. Sampling params stay untouched
+ (the 4.7+ sampling ban is a Claude-only contract)."""
+ from agent.anthropic_adapter import (
+ _supports_adaptive_thinking,
+ _supports_xhigh_effort,
+ _forbids_sampling_params,
+ )
+ for m in ("moonshotai/kimi-k2.5", "kimi-0714-preview", "k2-thinking"):
+ assert _supports_adaptive_thinking(m) is True, m
+ assert _supports_xhigh_effort(m) is True, m
+ assert _forbids_sampling_params(m) is False, m
+
+ def test_bare_k3_coding_plan_slug_is_kimi_family(self):
+ """Kimi Coding Plan serves K3 as the bare slug ``k3`` — it must be
+ classified as Kimi family (adaptive thinking) even on proxied
+ endpoints where only the model name is available. Lookalike
+ non-Kimi names must NOT match the exact-slug rule."""
+ from agent.anthropic_adapter import (
+ _model_name_is_kimi_family,
+ _supports_adaptive_thinking,
+ )
+ for m in ("k3", "K3", "moonshotai/k3", "k3.1-preview", "k3-turbo"):
+ assert _model_name_is_kimi_family(m) is True, m
+ assert _supports_adaptive_thinking("k3") is True
+ # Prefix-lookalikes without a separator must not be swept in.
+ for m in ("k30", "k3000-chat", "keras-3"):
+ assert _model_name_is_kimi_family(m) is False, m
+
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
diff --git a/tests/agent/test_anthropic_kimi_signed_thinking_replay.py b/tests/agent/test_anthropic_kimi_signed_thinking_replay.py
index 881ff1bd46ee..7e0581b2ebe0 100644
--- a/tests/agent/test_anthropic_kimi_signed_thinking_replay.py
+++ b/tests/agent/test_anthropic_kimi_signed_thinking_replay.py
@@ -12,7 +12,7 @@ MOONSHOT = "https://api.moonshot.cn/anthropic"
DEEPSEEK = "https://api.deepseek.com/anthropic"
-def _thinking_on_replay(base_url, signature=SIG):
+def _thinking_on_replay(base_url, signature=SIG, model="k3"):
"""Normalize a thinking+text turn, store it, convert to the next-turn request,
and return its thinking blocks."""
response = SimpleNamespace(
@@ -34,7 +34,7 @@ def _thinking_on_replay(base_url, signature=SIG):
stored,
{"role": "user", "content": "q2"},
]
- _sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model="k3")
+ _sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model=model)
assistant = [m for m in out if m.get("role") == "assistant"][0]
return [b for b in assistant["content"] if isinstance(b, dict) and b.get("type") == "thinking"]
@@ -54,7 +54,21 @@ def test_moonshot_keeps_signed_thinking():
def test_deepseek_still_strips_signed_thinking():
- assert not _thinking_on_replay(DEEPSEEK)
+ # A DeepSeek model on the DeepSeek Anthropic endpoint must strip signed
+ # thinking on replay. (The model must be a real DeepSeek slug: the bare
+ # ``k3`` slug is now classified as Kimi family, and a Kimi-family MODEL
+ # name deliberately preserves thinking regardless of gateway hostname —
+ # the proxied-endpoint path, see _is_kimi_family_endpoint.)
+ assert not _thinking_on_replay(DEEPSEEK, model="deepseek-reasoner")
+
+
+def test_kimi_model_name_on_foreign_gateway_keeps_thinking():
+ """A Kimi-family model slug replayed through a non-Kimi gateway hostname
+ keeps its thinking blocks — upstream Kimi still enforces its replay
+ semantics no matter what host fronts it (hermes-agent#13848, #17057).
+ Covers both the named and bare Coding Plan slugs."""
+ for model in ("kimi-k2.5", "k3"):
+ assert _thinking_on_replay(DEEPSEEK, model=model), model
def test_direct_anthropic_keeps_signed_on_latest():
diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py
index 26a59a33e2a3..97c6d43689e9 100644
--- a/tests/agent/test_bedrock_adapter.py
+++ b/tests/agent/test_bedrock_adapter.py
@@ -1165,13 +1165,55 @@ class TestBedrockErrorClassification:
class TestBedrockContextLength:
"""Test Bedrock model context length lookup."""
+ def test_claude_opus_4_8(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # Opus 4.8 exposes the 1M window on Bedrock (matches native Anthropic).
+ assert get_bedrock_context_length("anthropic.claude-opus-4-8-20250514-v1:0") == 1_000_000
+
+ def test_claude_opus_4_7(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # Opus 4.7 has 1M context generally available (no beta header required)
+ # per https://platform.claude.com/docs/en/about-claude/models/overview
+ assert get_bedrock_context_length("anthropic.claude-opus-4-7") == 1_000_000
+
def test_claude_opus_4_6(self):
from agent.bedrock_adapter import get_bedrock_context_length
- assert get_bedrock_context_length("anthropic.claude-opus-4-6-20250514-v1:0") == 200_000
+ # Opus 4.6 has 1M context generally available (no beta header required).
+ assert get_bedrock_context_length("anthropic.claude-opus-4-6-20250514-v1:0") == 1_000_000
+
+ def test_claude_fable_5(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # Fable is a 1M-context model. DEFAULT_CONTEXT_LENGTHS already maps
+ # claude-fable-5 -> 1M, but the Bedrock resolution path short-circuits
+ # to this table before consulting it, so without entries here every
+ # Fable inference profile fell through to
+ # BEDROCK_DEFAULT_CONTEXT_LENGTH (128K).
+ assert get_bedrock_context_length("us.anthropic.claude-fable-5") == 1_000_000
+ assert get_bedrock_context_length("global.anthropic.claude-fable-5") == 1_000_000
+ assert get_bedrock_context_length("anthropic.claude-fable-5-v1:0") == 1_000_000
+
+ def test_claude_opus_4_base_stays_200k(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # The original Opus 4 (no minor version) keeps the 200K window.
+ assert get_bedrock_context_length("anthropic.claude-opus-4-20250514-v1:0") == 200_000
def test_claude_sonnet_versioned(self):
from agent.bedrock_adapter import get_bedrock_context_length
- assert get_bedrock_context_length("anthropic.claude-sonnet-4-6-20250514-v1:0") == 200_000
+ # Sonnet 4.6 has 1M context generally available (no beta header required).
+ assert get_bedrock_context_length("anthropic.claude-sonnet-4-6-20250514-v1:0") == 1_000_000
+
+ def test_claude_sonnet_4_5_is_200k(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # Sonnet 4.5's 1M beta was retired on April 30, 2026;
+ # it is now standard 200K.
+ # https://platform.claude.com/docs/en/release-notes/overview
+ assert get_bedrock_context_length("anthropic.claude-sonnet-4-5-20250514-v1:0") == 200_000
+
+ def test_claude_haiku_4_5_is_200k(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ # Haiku 4.5 has no 1M window — must stay at the 200K Bedrock limit and
+ # not get swept up by the opus/sonnet bump.
+ assert get_bedrock_context_length("anthropic.claude-haiku-4-5-20251001-v1:0") == 200_000
def test_nova_pro(self):
from agent.bedrock_adapter import get_bedrock_context_length
@@ -1187,14 +1229,80 @@ class TestBedrockContextLength:
def test_inference_profile_resolves(self):
from agent.bedrock_adapter import get_bedrock_context_length
- # Cross-region inference profiles contain the base model ID
- assert get_bedrock_context_length("us.anthropic.claude-sonnet-4-6") == 200_000
+ # Cross-region inference profiles contain the base model ID.
+ # Sonnet 4.6 is 1M, so a 'us.' profile of it should also resolve to 1M.
+ assert get_bedrock_context_length("us.anthropic.claude-sonnet-4-6") == 1_000_000
def test_longest_prefix_wins(self):
from agent.bedrock_adapter import get_bedrock_context_length
# "anthropic.claude-3-5-sonnet" should match before "anthropic.claude-3"
assert get_bedrock_context_length("anthropic.claude-3-5-sonnet-20240620-v1:0") == 200_000
+ def test_no_region_skips_probe_uses_table(self):
+ # Default call (no region) must NOT hit the network — returns the
+ # static table value. Guards backward compatibility for callers that
+ # still invoke get_bedrock_context_length(model_id) with one arg.
+ from agent.bedrock_adapter import get_bedrock_context_length
+ with patch("agent.bedrock_adapter.probe_bedrock_context_length") as mock_probe:
+ assert get_bedrock_context_length("anthropic.claude-opus-4-6") == 1_000_000
+ mock_probe.assert_not_called()
+
+
+class TestBedrockContextProbe:
+ """Test the live context-window probe that reads the real window from
+ Bedrock's 'prompt is too long' validation error."""
+
+ def _client_raising(self, message):
+ client = MagicMock()
+ client.converse.side_effect = Exception(message)
+ return client
+
+ def test_probe_parses_real_window_from_error(self):
+ from agent.bedrock_adapter import probe_bedrock_context_length
+ err = (
+ "An error occurred (ValidationException) when calling the Converse "
+ "operation: The model returned the following errors: prompt is too "
+ "long: 5000032 tokens > 1000000 maximum"
+ )
+ with patch("agent.bedrock_adapter._get_bedrock_runtime_client",
+ return_value=self._client_raising(err)):
+ assert probe_bedrock_context_length(
+ "eu.anthropic.claude-opus-4-8", "eu-central-1") == 1_000_000
+
+ def test_probe_returns_none_on_unparseable_error(self):
+ from agent.bedrock_adapter import probe_bedrock_context_length
+ err = "An error occurred (AccessDeniedException): not authorized"
+ with patch("agent.bedrock_adapter._get_bedrock_runtime_client",
+ return_value=self._client_raising(err)):
+ assert probe_bedrock_context_length(
+ "eu.anthropic.claude-opus-4-8", "eu-central-1") is None
+
+ def test_probe_returns_none_when_client_unavailable(self):
+ from agent.bedrock_adapter import probe_bedrock_context_length
+ with patch("agent.bedrock_adapter._get_bedrock_runtime_client",
+ side_effect=RuntimeError("boto3 missing")):
+ assert probe_bedrock_context_length("any.model", "eu-central-1") is None
+
+ def test_probe_result_beats_static_table(self):
+ # A successful probe (1M) must override the stale table value (200K
+ # via the 'anthropic.claude-opus-4' substring match).
+ from agent.bedrock_adapter import get_bedrock_context_length
+ err = "prompt is too long: 5000032 tokens > 1000000 maximum"
+ with patch("agent.bedrock_adapter._get_bedrock_runtime_client",
+ return_value=self._client_raising(err)):
+ assert get_bedrock_context_length(
+ "eu.anthropic.claude-opus-4-8",
+ region="eu-central-1") == 1_000_000
+
+ def test_probe_failure_falls_back_to_table(self):
+ from agent.bedrock_adapter import get_bedrock_context_length
+ err = "AccessDeniedException: nope"
+ with patch("agent.bedrock_adapter._get_bedrock_runtime_client",
+ return_value=self._client_raising(err)):
+ # opus-4-6 is in the table at 1M; probe fails → table wins.
+ assert get_bedrock_context_length(
+ "anthropic.claude-opus-4-6", region="eu-central-1") == 1_000_000
+
# ---------------------------------------------------------------------------
# Tool-calling capability detection
@@ -1303,30 +1411,57 @@ class TestIsAnthropicBedrockModel:
from agent.bedrock_adapter import is_anthropic_bedrock_model
assert is_anthropic_bedrock_model("eu.anthropic.claude-sonnet-4-6") is True
+ def test_au_inference_profile(self):
+ from agent.bedrock_adapter import is_anthropic_bedrock_model
+ assert is_anthropic_bedrock_model("au.anthropic.claude-haiku-4-5-20251001-v1:0") is True
+ assert is_anthropic_bedrock_model("au.anthropic.claude-sonnet-4-6") is True
+
+ def test_apac_inference_profile(self):
+ from agent.bedrock_adapter import is_anthropic_bedrock_model
+ assert is_anthropic_bedrock_model("apac.anthropic.claude-sonnet-4-6") is True
+
class TestEmptyTextBlockFix:
- """Test that empty text blocks are replaced with space placeholders."""
+ """Test that empty/whitespace-only text blocks are replaced with a
+ non-whitespace placeholder (not a literal space, which is itself
+ whitespace and gets rejected by the same Bedrock validation rule)."""
- def test_none_content_gets_space(self):
- from agent.bedrock_adapter import _convert_content_to_converse
+ def test_none_content_gets_placeholder(self):
+ from agent.bedrock_adapter import _convert_content_to_converse, _EMPTY_TEXT_PLACEHOLDER
blocks = _convert_content_to_converse(None)
- assert blocks[0]["text"] == " "
+ assert blocks[0]["text"] == _EMPTY_TEXT_PLACEHOLDER
+ assert blocks[0]["text"].strip()
- def test_empty_string_gets_space(self):
- from agent.bedrock_adapter import _convert_content_to_converse
+ def test_empty_string_gets_placeholder(self):
+ from agent.bedrock_adapter import _convert_content_to_converse, _EMPTY_TEXT_PLACEHOLDER
blocks = _convert_content_to_converse("")
- assert blocks[0]["text"] == " "
+ assert blocks[0]["text"] == _EMPTY_TEXT_PLACEHOLDER
+ assert blocks[0]["text"].strip()
- def test_whitespace_only_gets_space(self):
- from agent.bedrock_adapter import _convert_content_to_converse
+ def test_whitespace_only_gets_placeholder(self):
+ from agent.bedrock_adapter import _convert_content_to_converse, _EMPTY_TEXT_PLACEHOLDER
blocks = _convert_content_to_converse(" ")
- assert blocks[0]["text"] == " "
+ assert blocks[0]["text"] == _EMPTY_TEXT_PLACEHOLDER
+ assert blocks[0]["text"].strip()
def test_real_text_preserved(self):
from agent.bedrock_adapter import _convert_content_to_converse
blocks = _convert_content_to_converse("Hello")
assert blocks[0]["text"] == "Hello"
+ def test_whitespace_only_list_string_item_gets_placeholder(self):
+ """Regression: plain string items inside a content list (not
+ {"type": "text"} dicts) must also be routed through _safe_text()."""
+ from agent.bedrock_adapter import _convert_content_to_converse, _EMPTY_TEXT_PLACEHOLDER
+ blocks = _convert_content_to_converse([" "])
+ assert blocks[0]["text"] == _EMPTY_TEXT_PLACEHOLDER
+ assert blocks[0]["text"].strip()
+
+ def test_real_list_string_item_preserved(self):
+ from agent.bedrock_adapter import _convert_content_to_converse
+ blocks = _convert_content_to_converse(["Hello"])
+ assert blocks[0]["text"] == "Hello"
+
# ---------------------------------------------------------------------------
# Stale-connection detection and per-region client invalidation
diff --git a/tests/agent/test_bedrock_empty_text_blocks.py b/tests/agent/test_bedrock_empty_text_blocks.py
new file mode 100644
index 000000000000..56511ed2f782
--- /dev/null
+++ b/tests/agent/test_bedrock_empty_text_blocks.py
@@ -0,0 +1,115 @@
+"""Regression tests for Bedrock Converse empty/whitespace text block rejection.
+
+Bedrock's Converse API raises::
+
+ ValidationException: The model returned the following errors: messages:
+ text content blocks must contain non-whitespace text
+
+for ANY text content block that is empty OR whitespace-only. Anthropic's native
+API tolerates these; Bedrock does not. Such blocks most often appear after
+context compression rewrites an assistant/tool turn into a blank string — once
+in history the block is re-sent on every call and fails deterministically
+(all retries fail identically). Ref: issue #9486.
+
+These tests assert convert_messages_to_converse never emits a blank text block
+(including inside toolResult content) and never uses a whitespace-only
+placeholder (a lone space would be rejected by the same validation).
+"""
+import pytest
+
+from agent.bedrock_adapter import (
+ convert_messages_to_converse,
+ _convert_content_to_converse,
+ _safe_text,
+ _EMPTY_TEXT_PLACEHOLDER,
+)
+
+
+def _iter_text_blocks(msgs):
+ """Yield every text string that will be sent to Bedrock, incl. toolResult."""
+ for m in msgs:
+ for b in m["content"]:
+ if "text" in b:
+ yield b["text"]
+ if "toolResult" in b:
+ for tb in b["toolResult"]["content"]:
+ if "text" in tb:
+ yield tb["text"]
+
+
+def test_placeholder_is_non_whitespace():
+ # The core lesson of #9486: a space is whitespace and is itself rejected.
+ assert _EMPTY_TEXT_PLACEHOLDER.strip(), "placeholder must be non-whitespace"
+
+
+@pytest.mark.parametrize("value", ["", " ", "\n\n", "\t", None])
+def test_safe_text_blank_inputs_become_non_whitespace(value):
+ assert _safe_text(value).strip()
+
+
+def test_safe_text_preserves_real_content():
+ assert _safe_text("hello") == "hello"
+ assert _safe_text(" padded ") == " padded " # inner content kept verbatim
+
+
+def test_no_blank_blocks_reach_bedrock():
+ """The exact failing history: blank system/assistant/tool/user turns."""
+ messages = [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "system", "content": [{"type": "text", "text": " "}]},
+ {"role": "user", "content": "search for foo"},
+ {"role": "assistant", "content": "",
+ "tool_calls": [{"id": "tc1",
+ "function": {"name": "search", "arguments": "{}"}}]},
+ {"role": "tool", "tool_call_id": "tc1", "content": ""}, # empty tool output
+ {"role": "assistant", "content": " \n\n "}, # whitespace-only (compaction)
+ {"role": "user", "content": [{"type": "text", "text": ""}]},
+ {"role": "assistant", "content": None},
+ ]
+ _system, msgs = convert_messages_to_converse(messages)
+ for text in _iter_text_blocks(msgs):
+ assert text.strip(), f"blank text block would be rejected by Bedrock: {text!r}"
+
+
+def test_empty_tool_result_gets_placeholder():
+ """A tool that returns no output must not produce a blank toolResult block."""
+ messages = [
+ {"role": "user", "content": "run it"},
+ {"role": "assistant", "content": "",
+ "tool_calls": [{"id": "t1", "function": {"name": "sh", "arguments": "{}"}}]},
+ {"role": "tool", "tool_call_id": "t1", "content": " "},
+ ]
+ _system, msgs = convert_messages_to_converse(messages)
+ tool_msg = next(m for m in msgs
+ if any("toolResult" in b for b in m["content"]))
+ block = next(b for b in tool_msg["content"] if "toolResult" in b)
+ text = block["toolResult"]["content"][0]["text"]
+ assert text.strip()
+
+
+def test_real_content_is_preserved_alongside_blank_siblings():
+ messages = [
+ {"role": "user", "content": [
+ {"type": "text", "text": " "},
+ {"type": "text", "text": "real question"},
+ ]},
+ ]
+ _system, msgs = convert_messages_to_converse(messages)
+ texts = list(_iter_text_blocks(msgs))
+ assert "real question" in texts
+ assert all(t.strip() for t in texts)
+
+
+def test_blank_system_blocks_dropped_not_blanked():
+ messages = [
+ {"role": "system", "content": [
+ {"type": "text", "text": "keep me"},
+ {"type": "text", "text": " "},
+ ]},
+ {"role": "user", "content": "hi"},
+ ]
+ system, _msgs = convert_messages_to_converse(messages)
+ assert system is not None
+ for block in system:
+ assert block["text"].strip()
+ assert any(b["text"] == "keep me" for b in system)
diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py
index d8f3a6a7fa9d..5296c67ec323 100644
--- a/tests/agent/test_compression_concurrent_fork.py
+++ b/tests/agent/test_compression_concurrent_fork.py
@@ -371,6 +371,42 @@ def test_empty_compression_result_does_not_rotate_session(tmp_path: Path) -> Non
assert db.get_session(parent_sid)["end_reason"] is None
+@pytest.mark.parametrize("in_place", [False, True])
+def test_equal_copy_compression_result_does_not_rewrite_session(
+ tmp_path: Path,
+ in_place: bool,
+) -> None:
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = f"EQUAL_COPY_NOOP_{in_place}"
+ db.create_session(parent_sid, source="cli")
+
+ agent = _build_agent_with_db(db, parent_sid)
+ setattr(agent, "compression_in_place", in_place)
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+ compressor = getattr(agent, "context_compressor")
+ compressor.compress.side_effect = lambda incoming, **_kw: list(incoming)
+
+ with patch.object(
+ db,
+ "archive_and_compact",
+ wraps=db.archive_and_compact,
+ ) as archive_and_compact:
+ returned, _sp = agent._compress_context(
+ messages,
+ "sys",
+ approx_tokens=120_000,
+ )
+
+ assert returned is messages
+ assert getattr(agent, "session_id") == parent_sid
+ assert _count_children(db, parent_sid) == 0
+ parent = db.get_session(parent_sid)
+ assert parent is not None
+ assert parent["end_reason"] is None
+ assert db.get_compression_lock_holder(parent_sid) is None
+ archive_and_compact.assert_not_called()
+
+
def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypatch) -> None:
"""The owning compression call must keep its lease alive while it runs."""
real_try_acquire = SessionDB.try_acquire_compression_lock
@@ -488,8 +524,8 @@ def test_abort_warning_exception_stops_lock_refresher(tmp_path: Path, monkeypatc
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
-def test_typeerror_fallback_exception_stops_lock_refresher(tmp_path: Path, monkeypatch) -> None:
- """A strict-signature fallback failure must still release the refreshed lock."""
+def test_internal_typeerror_stops_lock_refresher_without_retry(tmp_path: Path, monkeypatch) -> None:
+ """An engine TypeError must release the refreshed lock without a second call."""
real_try_acquire = SessionDB.try_acquire_compression_lock
def _short_ttl(self, session_id: str, holder: str, ttl_seconds: float = 300.0) -> bool:
@@ -505,22 +541,234 @@ def test_typeerror_fallback_exception_stops_lock_refresher(tmp_path: Path, monke
agent._compression_lock_ttl_seconds = 1.0
agent._compression_lock_refresh_interval = 0.1
- def _strict_signature(*_a, **_kw):
- if "focus_topic" in _kw or "force" in _kw:
- raise TypeError("strict signature")
- raise RuntimeError("fallback boom")
+ calls = []
- agent.context_compressor.compress.side_effect = _strict_signature
+ def _internal_typeerror(*_a, **_kw):
+ calls.append(_kw)
+ raise TypeError("engine implementation bug")
+
+ agent.context_compressor.compress.side_effect = _internal_typeerror
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
- with pytest.raises(RuntimeError, match="fallback boom"):
+ with pytest.raises(TypeError, match="engine implementation bug"):
agent._compress_context(messages, "sys", approx_tokens=120_000)
+ assert len(calls) == 1
time.sleep(1.3)
assert db.try_acquire_compression_lock(parent_sid, "probe", ttl_seconds=1.0) is True
+def test_lease_refresher_start_exception_releases_lock(tmp_path: Path, monkeypatch) -> None:
+ """A failed refresher start must not strand the lock until its TTL."""
+ refreshers = []
+
+ class FailingLeaseRefresher:
+ def __init__(self, *_args, **_kwargs):
+ self.stopped = False
+ refreshers.append(self)
+
+ def start(self):
+ raise RuntimeError("cannot start lock refresher")
+
+ def stop(self):
+ self.stopped = True
+
+ monkeypatch.setattr(
+ "agent.conversation_compression._CompressionLockLeaseRefresher",
+ FailingLeaseRefresher,
+ )
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = "REFRESHER_START_EXCEPTION_TEST"
+ db.create_session(parent_sid, source="discord")
+ agent = _build_agent_with_db(db, parent_sid)
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+
+ with pytest.raises(RuntimeError, match="cannot start lock refresher"):
+ agent._compress_context(messages, "sys", approx_tokens=120_000)
+
+ assert db.get_compression_lock_holder(parent_sid) is None
+ assert len(refreshers) == 1
+ assert refreshers[0].stopped is True
+
+
+def test_signature_introspection_exception_releases_lock_and_refresher(
+ tmp_path: Path, monkeypatch
+) -> None:
+ """Capability inspection failures must not leak the acquired lock lease."""
+ from agent.conversation_compression import (
+ _CompressionLockLeaseRefresher as RealLeaseRefresher,
+ )
+
+ refreshers = []
+
+ class RecordingLeaseRefresher(RealLeaseRefresher):
+ def start(self):
+ refreshers.append(self)
+ return super().start()
+
+ monkeypatch.setattr(
+ "agent.conversation_compression._CompressionLockLeaseRefresher",
+ RecordingLeaseRefresher,
+ )
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = "SIGNATURE_EXCEPTION_TEST"
+ db.create_session(parent_sid, source="discord")
+
+ agent = _build_agent_with_db(db, parent_sid)
+ agent._compression_lock_refresh_interval = 0.1
+
+ class SignatureBomb:
+ calls = 0
+
+ @property
+ def __signature__(self):
+ raise RuntimeError("signature boom")
+
+ def __call__(self, *_args, **_kwargs):
+ self.calls += 1
+ raise AssertionError("engine must not run after signature failure")
+
+ bomb = SignatureBomb()
+ agent.context_compressor.compress = bomb
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+
+ with pytest.raises(RuntimeError, match="signature boom"):
+ agent._compress_context(messages, "sys", approx_tokens=120_000)
+
+ assert bomb.calls == 0
+ assert db.get_compression_lock_holder(parent_sid) is None
+ assert len(refreshers) == 1
+ assert not refreshers[0]._thread.is_alive()
+
+
+def test_noop_prompt_exception_releases_lock_and_refresher(
+ tmp_path: Path, monkeypatch
+) -> None:
+ """No-op prompt rebuild failures must not escape the lock cleanup scope."""
+ from agent.conversation_compression import (
+ _CompressionLockLeaseRefresher as RealLeaseRefresher,
+ )
+
+ refreshers = []
+
+ class RecordingLeaseRefresher(RealLeaseRefresher):
+ def start(self):
+ refreshers.append(self)
+ return super().start()
+
+ monkeypatch.setattr(
+ "agent.conversation_compression._CompressionLockLeaseRefresher",
+ RecordingLeaseRefresher,
+ )
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = "NOOP_PROMPT_EXCEPTION_TEST"
+ db.create_session(parent_sid, source="discord")
+ agent = _build_agent_with_db(db, parent_sid)
+ agent._compression_lock_refresh_interval = 0.1
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+ agent.context_compressor.compress.side_effect = lambda *_a, **_kw: messages
+ agent._cached_system_prompt = None
+ agent._build_system_prompt = lambda *_a, **_kw: (_ for _ in ()).throw(
+ RuntimeError("prompt rebuild boom")
+ )
+
+ with pytest.raises(RuntimeError, match="prompt rebuild boom"):
+ agent._compress_context(messages, "sys", approx_tokens=120_000)
+
+ assert db.get_compression_lock_holder(parent_sid) is None
+ assert len(refreshers) == 1
+ assert not refreshers[0]._thread.is_alive()
+
+
+def test_post_dispatch_attribute_exception_releases_lock_and_refresher(
+ tmp_path: Path, monkeypatch
+) -> None:
+ """Plugin state lookup failures after dispatch must release the lock."""
+ from agent.conversation_compression import (
+ _CompressionLockLeaseRefresher as RealLeaseRefresher,
+ )
+
+ refreshers = []
+
+ class RecordingLeaseRefresher(RealLeaseRefresher):
+ def start(self):
+ refreshers.append(self)
+ return super().start()
+
+ class AttributeBombEngine:
+ name = "attribute-bomb"
+
+ def compress(self, messages, **_kwargs):
+ return [messages[0], messages[-1]]
+
+ def __getattribute__(self, name):
+ if name == "_last_compression_made_progress":
+ raise RuntimeError("post-dispatch attribute boom")
+ return object.__getattribute__(self, name)
+
+ monkeypatch.setattr(
+ "agent.conversation_compression._CompressionLockLeaseRefresher",
+ RecordingLeaseRefresher,
+ )
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = "POST_DISPATCH_ATTRIBUTE_EXCEPTION_TEST"
+ db.create_session(parent_sid, source="discord")
+ agent = _build_agent_with_db(db, parent_sid)
+ agent._compression_lock_refresh_interval = 0.1
+ agent.context_compressor = AttributeBombEngine()
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+
+ with pytest.raises(RuntimeError, match="post-dispatch attribute boom"):
+ agent._compress_context(messages, "sys", approx_tokens=120_000)
+
+ assert db.get_compression_lock_holder(parent_sid) is None
+ assert len(refreshers) == 1
+ assert not refreshers[0]._thread.is_alive()
+
+
+def test_refresher_stop_exception_does_not_block_lock_release(
+ tmp_path: Path, monkeypatch
+) -> None:
+ """Refresher cleanup failure must not prevent holder-qualified DB release."""
+ refreshers = []
+
+ class StopFailingLeaseRefresher:
+ def __init__(self, *_args, **_kwargs):
+ self.stop_calls = 0
+ refreshers.append(self)
+
+ def start(self):
+ return self
+
+ def stop(self):
+ self.stop_calls += 1
+ raise RuntimeError("refresher stop boom")
+
+ monkeypatch.setattr(
+ "agent.conversation_compression._CompressionLockLeaseRefresher",
+ StopFailingLeaseRefresher,
+ )
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ parent_sid = "REFRESHER_STOP_EXCEPTION_TEST"
+ db.create_session(parent_sid, source="discord")
+ agent = _build_agent_with_db(db, parent_sid)
+ agent.context_compressor.compress.side_effect = RuntimeError("engine boom")
+ messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
+
+ with pytest.raises(RuntimeError, match="engine boom"):
+ agent._compress_context(messages, "sys", approx_tokens=120_000)
+
+ assert db.get_compression_lock_holder(parent_sid) is None
+ assert len(refreshers) == 1
+ assert refreshers[0].stop_calls == 1
+
+
def _make_legacy_session_db_class() -> type:
"""Model the class retained in ``sys.modules`` before the lock API existed.
diff --git a/tests/agent/test_compressor_tail_cut_tool_pair_floor.py b/tests/agent/test_compressor_tail_cut_tool_pair_floor.py
new file mode 100644
index 000000000000..ad66965c12e9
--- /dev/null
+++ b/tests/agent/test_compressor_tail_cut_tool_pair_floor.py
@@ -0,0 +1,187 @@
+"""Regression coverage: the minimum-progress floor must not split a tool group.
+
+``_find_tail_cut_by_tokens`` aligns ``cut_idx`` away from tool-call/result
+boundaries (``_align_boundary_backward``, #1976) and both tail anchors
+re-align after moving it. The final statement then raised the result to
+``head_end + 1`` to guarantee compression always claims at least one message
+— otherwise the caller's ``compress_start >= compress_end`` guard turns the
+pass into a no-op that re-runs forever.
+
+That raise discarded the alignment. When the floor landed *inside* a tool
+group, the parent ``assistant(tool_calls)`` fell in the summarised region
+while its ``tool`` results started the tail; ``_sanitize_tool_pairs`` then
+dropped those orphans outright, so the tool output was neither summarised nor
+kept — it vanished. That is precisely the silent loss
+``_align_boundary_backward``'s docstring says the alignment exists to prevent.
+
+The floor now re-aligns FORWARD (never backward, which would hand back the
+message the floor just claimed), so a raised cut skips to the end of the
+group and the call/result pair is summarised together.
+"""
+
+from __future__ import annotations
+
+import itertools
+
+import pytest
+
+
+@pytest.fixture()
+def compressor():
+ from agent.context_compressor import ContextCompressor
+
+ return ContextCompressor(model="main-model", quiet_mode=True)
+
+
+def _tool_group(call_id: str, results: int = 1, payload: str = "r" * 60):
+ msgs = [{
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [{
+ "id": call_id,
+ "type": "function",
+ "function": {"name": "f", "arguments": "{}"},
+ }],
+ }]
+ for _ in range(results):
+ msgs.append({"role": "tool", "tool_call_id": call_id, "content": payload})
+ return msgs
+
+
+def _cut(compressor, messages):
+ head_end = compressor._protect_head_size(messages)
+ start = compressor._align_boundary_forward(messages, head_end)
+ return start, compressor._find_tail_cut_by_tokens(messages, start)
+
+
+def _pairing_violations(messages, start, end):
+ """Indices kept (head + tail) must never reference a summarised partner."""
+ n = len(messages)
+ kept = set(range(0, min(start, n))) | set(range(min(end, n), n))
+ parent = {}
+ for i, m in enumerate(messages):
+ for tc in m.get("tool_calls") or []:
+ parent[tc["id"]] = i
+
+ problems = []
+ for i in sorted(kept):
+ msg = messages[i]
+ for tc in msg.get("tool_calls") or []:
+ for j, other in enumerate(messages):
+ if (
+ other.get("role") == "tool"
+ and other.get("tool_call_id") == tc["id"]
+ and j not in kept
+ ):
+ problems.append(
+ f"assistant(tool_calls) kept at {i}, result {j} summarised"
+ )
+ if msg.get("role") == "tool":
+ p = parent.get(msg.get("tool_call_id"))
+ if p is not None and p not in kept:
+ problems.append(f"tool kept at {i}, parent assistant {p} summarised")
+ return problems
+
+
+class TestFloorDoesNotSplitToolGroups:
+ def test_back_to_back_tool_calls_keep_their_results(self, compressor):
+ """Two consecutive tool calls — the ordinary agent shape.
+
+ The floor used to land on the second group's ``tool`` result, leaving
+ it orphaned in the tail while its parent was summarised away.
+ """
+ messages = [{"role": "system", "content": "sys"}]
+ messages += _tool_group("call_1", results=2)
+ messages += _tool_group("call_2", results=1, payload="IMPORTANT RESULT")
+
+ start, end = _cut(compressor, messages)
+
+ assert not _pairing_violations(messages, start, end)
+ # The orphan is gone because the whole group moved into the summary.
+ summarised = messages[start:end]
+ assert any(
+ m.get("role") == "tool" and "IMPORTANT RESULT" in str(m.get("content"))
+ for m in summarised
+ ), "the tool result must be summarised with its parent, not dropped"
+
+ def test_tool_result_is_not_silently_dropped_by_sanitize(self, compressor):
+ """End-to-end: the surviving transcript keeps a coherent pairing."""
+ messages = [{"role": "system", "content": "sys"}]
+ messages += _tool_group("call_1", results=2)
+ messages += _tool_group("call_2", results=1, payload="IMPORTANT RESULT")
+
+ start, end = _cut(compressor, messages)
+ survivors = (
+ messages[:start]
+ + [{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] ..."}]
+ + messages[end:]
+ )
+ cleaned = compressor._sanitize_tool_pairs(survivors)
+
+ assert len(cleaned) == len(survivors), (
+ "_sanitize_tool_pairs had to strip an orphan — the cut split a group"
+ )
+
+ def test_floor_lands_on_tool_result_after_protected_head(self, compressor):
+ """The floor path itself: head_end + 1 points straight at a result."""
+ messages = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "u" * 60},
+ {"role": "user", "content": "u" * 60},
+ {"role": "user", "content": "u" * 60},
+ ]
+ messages += _tool_group("call_1", results=1)
+
+ start, end = _cut(compressor, messages)
+
+ assert not _pairing_violations(messages, start, end)
+ assert messages[end - 1].get("role") != "assistant" or not messages[
+ end - 1
+ ].get("tool_calls"), "cut must not sit between a tool_call and its result"
+
+ def test_cut_still_makes_progress(self, compressor):
+ """The floor's purpose survives: compression always claims a message."""
+ messages = [{"role": "system", "content": "sys"}]
+ messages += _tool_group("call_1", results=1)
+ messages += _tool_group("call_2", results=1)
+
+ start, end = _cut(compressor, messages)
+
+ assert end > start, "compression must not become a no-op"
+
+
+class TestToolPairingInvariantAcrossShapes:
+ def test_no_well_formed_transcript_is_split_mid_group(self, compressor):
+ """Property sweep over every well-formed block layout up to length 6.
+
+ Blocks: user, assistant-text, tool group with one result, tool group
+ with two results. On the unfixed floor ~26% of these split a pair.
+ """
+ blocks = ["U", "A", "T1", "T2"]
+ offenders = []
+ exercised = 0
+
+ for n in range(2, 7):
+ for layout in itertools.product(blocks, repeat=n):
+ messages = [{"role": "system", "content": "sys"}]
+ for i, b in enumerate(layout):
+ if b == "U":
+ messages.append({"role": "user", "content": "u" * 60})
+ elif b == "A":
+ messages.append({"role": "assistant", "content": "a" * 60})
+ else:
+ messages += _tool_group(
+ f"call_{i}", results=1 if b == "T1" else 2
+ )
+
+ start, end = _cut(compressor, messages)
+ if end <= start:
+ continue
+ exercised += 1
+ if _pairing_violations(messages, start, end):
+ offenders.append("-".join(layout))
+
+ assert exercised > 1000, "sweep did not exercise a meaningful sample"
+ assert not offenders, (
+ f"{len(offenders)} layouts split a tool group, e.g. {offenders[:5]}"
+ )
diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py
index f3101913ceb7..7c2b85585388 100644
--- a/tests/agent/test_context_compressor_summary_continuity.py
+++ b/tests/agent/test_context_compressor_summary_continuity.py
@@ -75,7 +75,11 @@ def test_handoff_in_protected_head_populates_previous_summary_before_update():
old_summary = "PROTECTED-HEAD-SUMMARY durable facts from before restart"
seen_turns = []
- def fake_generate_summary(turns_to_summarize, focus_topic=None):
+ def fake_generate_summary(
+ turns_to_summarize,
+ focus_topic=None,
+ memory_context="",
+ ):
seen_turns.extend(turns_to_summarize)
return "new summary from resumed turns"
diff --git a/tests/agent/test_kimi_coding_anthropic_thinking.py b/tests/agent/test_kimi_coding_anthropic_thinking.py
index 89872cc2f006..a58e584c715f 100644
--- a/tests/agent/test_kimi_coding_anthropic_thinking.py
+++ b/tests/agent/test_kimi_coding_anthropic_thinking.py
@@ -1,22 +1,20 @@
-"""Regression guard: don't send Anthropic ``thinking`` to Kimi's /coding endpoint.
+"""Kimi / Moonshot thinking behavior on the Anthropic-Messages wire.
-Kimi's ``api.kimi.com/coding`` endpoint speaks the Anthropic Messages protocol
-but has its own thinking semantics. When ``thinking.enabled`` is present in
-the request, Kimi validates the message history and requires every prior
-assistant tool-call message to carry OpenAI-style ``reasoning_content``.
+Contract (changed from the original #13848 mitigation):
-The Anthropic path never populates that field, and
-``convert_messages_to_anthropic`` strips Anthropic thinking blocks on
-third-party endpoints — so after one turn with tool calls the next request
-fails with HTTP 400::
+- Kimi-family endpoints receive ``thinking`` in **adaptive** form
+ (``thinking.type="adaptive"`` + ``output_config.effort``) — never manual
+ ``budget_tokens``. Their Anthropic-compatible endpoints
+ (``api.moonshot.cn/anthropic``, ``api.kimi.com/coding``) accept the
+ field set, and the replay-validation 400s that originally motivated
+ dropping the parameter (#13848) no longer occur.
- thinking is enabled but reasoning_content is missing in assistant
- tool call message at index N
+- ``convert_messages_to_anthropic`` still preserves unsigned
+ reasoning_content-derived thinking blocks on replay for this family, so
+ multi-turn tool-call history round-trips.
-Kimi on the chat_completions route handles ``thinking`` via ``extra_body`` in
-``ChatCompletionsTransport`` (#13503). On the Anthropic route the right
-thing to do is drop the parameter entirely and let Kimi drive reasoning
-server-side.
+Kimi on the chat_completions route handles ``thinking`` via ``extra_body``
+in ``ChatCompletionsTransport`` (#13503).
"""
from __future__ import annotations
@@ -24,36 +22,10 @@ from __future__ import annotations
import pytest
-class TestKimiCodingSkipsAnthropicThinking:
- """build_anthropic_kwargs must not inject ``thinking`` for Kimi /coding."""
+class TestKimiCodingAnthropicThinking:
+ """Kimi-family thinking on the Anthropic wire (incl. /coding)."""
- @pytest.mark.parametrize(
- "base_url",
- [
- "https://api.kimi.com/coding",
- "https://api.kimi.com/coding/v1",
- "https://api.kimi.com/coding/anthropic",
- "https://api.kimi.com/coding/",
- ],
- )
- def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None:
- from agent.anthropic_adapter import build_anthropic_kwargs
-
- kwargs = build_anthropic_kwargs(
- model="kimi-k2.5",
- messages=[{"role": "user", "content": "hello"}],
- tools=None,
- max_tokens=4096,
- reasoning_config={"enabled": True, "effort": "medium"},
- base_url=base_url,
- )
- assert "thinking" not in kwargs, (
- "Anthropic thinking must not be sent to Kimi /coding — "
- "endpoint requires reasoning_content on history we don't preserve."
- )
- assert "output_config" not in kwargs
-
- def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None:
+ def test_kimi_coding_with_explicit_disabled_omits_thinking(self) -> None:
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
@@ -94,48 +66,29 @@ class TestKimiCodingSkipsAnthropicThinking:
)
assert "thinking" in kwargs
- def test_kimi_root_endpoint_via_anthropic_transport_omits_thinking(self) -> None:
- """Plain ``api.kimi.com`` hit via the Anthropic transport also omits thinking.
- Auto-detection routes ``api.kimi.com/v1`` to ``chat_completions`` by
- default, but users can explicitly configure
- ``api_mode: anthropic_messages`` against any Kimi host. The upstream
- validation (reasoning_content required on replayed tool-call
- messages) is the same regardless of URL path, so the thinking
- suppression must apply to every Kimi host, not just ``/coding``.
- See #17057.
- """
- from agent.anthropic_adapter import build_anthropic_kwargs
+class TestKimiFamilyGetsAdaptiveThinking:
+ """Kimi-family endpoints get adaptive thinking + output_config.effort."""
- kwargs = build_anthropic_kwargs(
- model="kimi-k2.5",
- messages=[{"role": "user", "content": "hello"}],
- tools=None,
- max_tokens=4096,
- reasoning_config={"enabled": True, "effort": "medium"},
- base_url="https://api.kimi.com/v1",
- )
- assert "thinking" not in kwargs
-
- # ── #17057: custom / proxied Kimi-compatible endpoints ──────────
@pytest.mark.parametrize(
"base_url,model",
[
- # Custom host with Kimi-family model — the reporter's case
- ("http://my-kimi-proxy.internal", "kimi-2.6"),
- ("https://llm.example.com/anthropic", "kimi-k2.5"),
- ("https://llm.example.com/anthropic", "moonshot-v1-8k"),
- ("https://llm.example.com/anthropic", "kimi_thinking"),
- ("https://llm.example.com/anthropic", "moonshotai/kimi-k2.5"),
- # Official Moonshot host (previously uncovered)
+ # Official Kimi / Moonshot hosts (all URL shapes)
+ ("https://api.kimi.com/coding", "kimi-k2.5"),
+ ("https://api.kimi.com/coding/v1", "kimi-k2.5"),
+ ("https://api.kimi.com/coding/anthropic", "kimi-k2.5"),
+ ("https://api.kimi.com/v1", "kimi-k2.5"),
("https://api.moonshot.ai/anthropic", "moonshot-v1-32k"),
("https://api.moonshot.cn/anthropic", "moonshot-v1-32k"),
+ ("https://api.moonshot.cn/anthropic/v1", "kimi-0714-preview"),
+ # Custom / proxied hosts with a Kimi-family model (#17057)
+ ("http://my-kimi-proxy.internal", "kimi-2.6"),
+ ("https://llm.example.com/anthropic", "moonshotai/kimi-k2.5"),
],
)
- def test_kimi_family_custom_endpoint_omits_thinking(
+ def test_kimi_family_endpoint_gets_adaptive_thinking(
self, base_url: str, model: str
) -> None:
- """Custom / proxied Kimi endpoints must also strip Anthropic thinking."""
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
@@ -143,21 +96,65 @@ class TestKimiCodingSkipsAnthropicThinking:
messages=[{"role": "user", "content": "hello"}],
tools=None,
max_tokens=4096,
- reasoning_config={"enabled": True, "effort": "medium"},
+ reasoning_config={"enabled": True, "effort": "high"},
base_url=base_url,
)
- assert "thinking" not in kwargs, (
- f"Kimi-family endpoint ({base_url}, {model}) must not receive "
- f"Anthropic thinking — upstream validates reasoning_content on "
- f"replayed tool-call history we don't preserve."
+ assert kwargs.get("thinking", {}).get("type") == "adaptive", (
+ f"Kimi-family endpoint ({base_url}, {model}) must receive "
+ f"adaptive thinking, got {kwargs.get('thinking')!r}"
)
+ assert "budget_tokens" not in kwargs["thinking"]
+ assert kwargs["output_config"] == {"effort": "high"}
+ # Adaptive mode must not force temperature or inflate max_tokens
+ # (those are manual-budget-mode side effects).
+ assert "temperature" not in kwargs
+ assert kwargs["max_tokens"] == 4096
+
+ @pytest.mark.parametrize(
+ "hermes_effort,wire_effort",
+ [
+ ("minimal", "low"),
+ ("low", "low"),
+ ("medium", "medium"),
+ ("high", "high"),
+ ("xhigh", "xhigh"),
+ ("max", "max"),
+ ("ultra", "max"),
+ ],
+ )
+ def test_kimi_effort_mapping(self, hermes_effort: str, wire_effort: str) -> None:
+ from agent.anthropic_adapter import build_anthropic_kwargs
+
+ kwargs = build_anthropic_kwargs(
+ model="kimi-0714-preview",
+ messages=[{"role": "user", "content": "hello"}],
+ tools=None,
+ max_tokens=4096,
+ reasoning_config={"enabled": True, "effort": hermes_effort},
+ base_url="https://api.moonshot.cn/anthropic/v1",
+ )
+ assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
+ assert kwargs["output_config"] == {"effort": wire_effort}
+
+ def test_kimi_thinking_disabled_omits_parameter(self) -> None:
+ from agent.anthropic_adapter import build_anthropic_kwargs
+
+ kwargs = build_anthropic_kwargs(
+ model="kimi-0714-preview",
+ messages=[{"role": "user", "content": "hello"}],
+ tools=None,
+ max_tokens=4096,
+ reasoning_config={"enabled": False},
+ base_url="https://api.moonshot.cn/anthropic/v1",
+ )
+ assert "thinking" not in kwargs
assert "output_config" not in kwargs
def test_custom_endpoint_non_kimi_model_keeps_thinking(self) -> None:
"""Custom endpoint with a non-Kimi model must keep thinking intact.
Guards against over-broad model-family matching — only model names
- starting with a Kimi/Moonshot prefix should trigger suppression.
+ starting with a Kimi/Moonshot prefix should route to adaptive.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py
index 2dd1510786e7..1152514c1e52 100644
--- a/tests/agent/test_minimax_provider.py
+++ b/tests/agent/test_minimax_provider.py
@@ -330,6 +330,7 @@ class TestMinimaxMaxOutput:
from agent.anthropic_adapter import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
+ assert _get_anthropic_max_output("claude-sonnet-5") == 128_000
class TestMinimaxPreserveDots:
diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py
index 64f7573afcc2..4b0b327682f2 100644
--- a/tests/agent/test_model_metadata.py
+++ b/tests/agent/test_model_metadata.py
@@ -175,7 +175,15 @@ class TestEstimateRequestTokensRough:
class TestDefaultContextLengths:
def test_k3_context_is_scoped_to_confirmed_coding_endpoint(self):
- """K3's 1 Mi context must not leak to unverified Moonshot endpoints."""
+ """The bare ``k3`` slug's 1 Mi context must not leak to unverified endpoints.
+
+ The named ``kimi-k3`` / ``kimi-k3-cot`` slugs resolve to 1 Mi
+ EVERYWHERE via DEFAULT_CONTEXT_LENGTHS — the window is a property of
+ the model, served at 1M on api.moonshot.ai and api.moonshot.cn alike
+ (verified against models.dev + OpenRouter live metadata). Only the
+ bare ``k3`` slug, which exists solely on the Kimi Coding Plan
+ endpoint, stays endpoint-scoped.
+ """
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
@@ -199,14 +207,22 @@ class TestDefaultContextLengths:
)
for base_url in accepted_urls:
- assert get_model_context_length(
- "k3", provider="kimi-coding", base_url=base_url
- ) == 1_048_576
+ for model in ("k3", "kimi-k3", "kimi-k3-cot"):
+ assert get_model_context_length(
+ model, provider="kimi-coding", base_url=base_url
+ ) == 1_048_576
for base_url in rejected_urls:
+ # Bare slug: endpoint-scoped, must NOT leak off-endpoint.
assert get_model_context_length(
"k3", provider="kimi-coding", base_url=base_url
) != 1_048_576
+ # Named slugs: global DEFAULT_CONTEXT_LENGTHS entry applies
+ # everywhere the model is actually named kimi-k3.
+ for model in ("kimi-k3", "kimi-k3-cot"):
+ assert get_model_context_length(
+ model, provider="kimi-coding", base_url=base_url
+ ) == 1_048_576
def test_grok_substring_matching(self):
# Longest-first substring matching must resolve the real xAI model
@@ -330,6 +346,32 @@ class TestDefaultContextLengths:
assert get_model_context_length("glm-5") == 202752
assert get_model_context_length("glm-5.1") == 202752
+ def test_kimi_k3_context_1m(self):
+ """Kimi K3 must resolve to 1M, not the generic Kimi fallback of 256K.
+
+ Context window verified against models.dev and OpenRouter live
+ metadata (1,048,576; 2026-07). Kimi K3 is the current flagship model
+ with a 1M-token context window — the value matches the
+ endpoint-scoped override in _endpoint_scoped_context_length.
+ """
+ from agent.model_metadata import get_model_context_length
+ from unittest.mock import patch as mock_patch
+
+ assert DEFAULT_CONTEXT_LENGTHS["kimi-k3"] == 1_048_576
+ assert DEFAULT_CONTEXT_LENGTHS["kimi"] == 262144
+
+ with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
+ mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
+ mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
+ # Kimi K3 (1M) must NOT fall through to the generic 256K entry
+ assert get_model_context_length("kimi-k3") == 1_048_576
+ # Vendor-prefixed forms (kimi provider, openrouter)
+ assert get_model_context_length("kimi/kimi-k3") == 1_048_576
+ assert get_model_context_length("moonshotai/kimi-k3") == 1_048_576
+ # Older/unknown Kimi models still resolve to 256K fallback
+ assert get_model_context_length("kimi-k2.6") == 262144
+ assert get_model_context_length("kimi-k2") == 262144
+
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
"""OpenRouter-routed slugs resolve via the live OR catalog before the
hardcoded family catch-all.
@@ -1117,6 +1159,49 @@ class TestBedrockContextResolution:
assert ctx == 200000
mock_fetch.assert_not_called()
+ @patch("agent.model_metadata.fetch_endpoint_model_metadata")
+ def test_bedrock_claude_4_6_resolves_to_1m_before_probe(self, mock_fetch):
+ """Claude 4.6 Bedrock IDs resolve to the 1M table entry."""
+ ctx = get_model_context_length(
+ "us.anthropic.claude-sonnet-4-6",
+ provider="bedrock",
+ base_url="https://bedrock-runtime.us-east-2.amazonaws.com",
+ )
+ assert ctx == 1_000_000
+ mock_fetch.assert_not_called()
+
+ @patch("agent.model_metadata.fetch_endpoint_model_metadata")
+ def test_bedrock_claude_fable_resolves_to_1m_not_128k_default(self, mock_fetch):
+ """Fable on Bedrock must hit its own table entry, not the 128K default.
+
+ DEFAULT_CONTEXT_LENGTHS maps claude-fable-5 -> 1M, but the Bedrock
+ branch at step 1b returns get_bedrock_context_length() before that
+ catalog is ever consulted — so a missing BEDROCK_CONTEXT_LENGTHS
+ entry silently reported 128K for a 1M model.
+ """
+ ctx = get_model_context_length(
+ "global.anthropic.claude-fable-5",
+ provider="bedrock",
+ base_url="https://bedrock-runtime.us-east-2.amazonaws.com",
+ )
+ assert ctx == 1_000_000
+ mock_fetch.assert_not_called()
+
+ @patch("agent.model_metadata.fetch_endpoint_model_metadata")
+ def test_bedrock_claude_4_6_ignores_stale_200k_cache(self, mock_fetch, tmp_path):
+ """Old 200K Bedrock cache entries must not mask the 1M table entry."""
+ cache_file = tmp_path / "context_length_cache.yaml"
+ base_url = "https://bedrock-runtime.us-east-2.amazonaws.com"
+ with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file):
+ save_context_length("us.anthropic.claude-sonnet-4-6", base_url, 200_000)
+ ctx = get_model_context_length(
+ "us.anthropic.claude-sonnet-4-6",
+ provider="bedrock",
+ base_url=base_url,
+ )
+ assert ctx == 1_000_000
+ mock_fetch.assert_not_called()
+
@patch("agent.model_metadata.fetch_endpoint_model_metadata")
def test_bedrock_url_without_provider_hint(self, mock_fetch):
"""bedrock-runtime host infers Bedrock even when provider is omitted."""
diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py
index 69727f9ab778..6dc7944292bc 100644
--- a/tests/agent/test_moonshot_schema.py
+++ b/tests/agent/test_moonshot_schema.py
@@ -29,6 +29,10 @@ class TestMoonshotModelDetection:
[
"kimi-k2.6",
"kimi-k2-thinking",
+ "k3",
+ "K3",
+ "moonshotai/k3",
+ "k3.1-preview",
"moonshotai/Kimi-K2.6",
"moonshotai/kimi-k2.6",
"nous/moonshotai/kimi-k2.6",
@@ -184,9 +188,10 @@ class TestTopLevelGuarantees:
"""The returned top-level schema is always a well-formed object."""
def test_non_dict_input_returns_empty_object(self):
- assert sanitize_moonshot_tool_parameters(None) == {"type": "object", "properties": {}}
- assert sanitize_moonshot_tool_parameters("garbage") == {"type": "object", "properties": {}}
- assert sanitize_moonshot_tool_parameters([]) == {"type": "object", "properties": {}}
+ empty = {"type": "object", "properties": {}, "required": []}
+ assert sanitize_moonshot_tool_parameters(None) == empty
+ assert sanitize_moonshot_tool_parameters("garbage") == empty
+ assert sanitize_moonshot_tool_parameters([]) == empty
def test_non_object_top_level_coerced(self):
params = {"type": "string"}
@@ -208,6 +213,66 @@ class TestTopLevelGuarantees:
assert "type" not in params["properties"]["q"]
+class TestRequiredArray:
+ """Rule 4: every object schema must carry a ``required`` array (#66835)."""
+
+ def test_empty_object_gets_empty_required(self):
+ out = sanitize_moonshot_tool_parameters({"type": "object", "properties": {}})
+ assert out["required"] == []
+
+ def test_object_with_only_optional_props_gets_empty_required(self):
+ params = {
+ "type": "object",
+ "properties": {"q": {"type": "string"}},
+ }
+ out = sanitize_moonshot_tool_parameters(params)
+ assert out["required"] == []
+
+ def test_existing_required_preserved(self):
+ params = {
+ "type": "object",
+ "properties": {"q": {"type": "string"}},
+ "required": ["q"],
+ }
+ out = sanitize_moonshot_tool_parameters(params)
+ assert out["required"] == ["q"]
+
+ def test_dangling_required_pruned(self):
+ params = {
+ "type": "object",
+ "properties": {"q": {"type": "string"}},
+ "required": ["q", "ghost"],
+ }
+ out = sanitize_moonshot_tool_parameters(params)
+ assert out["required"] == ["q"]
+
+ def test_non_list_required_replaced(self):
+ params = {
+ "type": "object",
+ "properties": {},
+ "required": "q", # invalid: string, not array
+ }
+ out = sanitize_moonshot_tool_parameters(params)
+ assert out["required"] == []
+
+ def test_nested_object_property_gets_required(self):
+ params = {
+ "type": "object",
+ "properties": {
+ "filter": {"type": "object", "properties": {}},
+ },
+ }
+ out = sanitize_moonshot_tool_parameters(params)
+ assert out["properties"]["filter"]["required"] == []
+ assert out["required"] == []
+
+ def test_coerced_top_level_gets_required(self):
+ # A non-object top level is forced to object and must gain required.
+ out = sanitize_moonshot_tool_parameters({"type": "string"})
+ assert out["type"] == "object"
+ assert out["required"] == []
+
+
class TestToolListSanitizer:
"""sanitize_moonshot_tools() walks an OpenAI-format tool list."""
@@ -235,8 +300,10 @@ class TestToolListSanitizer:
]
out = sanitize_moonshot_tools(tools)
assert out[0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
- # Second tool already clean — should be structurally equivalent
- assert out[1]["function"]["parameters"] == {"type": "object", "properties": {}}
+ # Second tool: empty object gains the required-array Moonshot demands
+ assert out[1]["function"]["parameters"] == {
+ "type": "object", "properties": {}, "required": []
+ }
def test_empty_list_is_passthrough(self):
assert sanitize_moonshot_tools([]) == []
diff --git a/tests/agent/test_pre_compress_memory_context.py b/tests/agent/test_pre_compress_memory_context.py
new file mode 100644
index 000000000000..14f8addb42a8
--- /dev/null
+++ b/tests/agent/test_pre_compress_memory_context.py
@@ -0,0 +1,277 @@
+"""Behavior contracts for memory-provider context in compression prompts."""
+
+import json
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from agent.context_compressor import ContextCompressor
+
+
+def _make_compressor():
+ compressor = ContextCompressor.__new__(ContextCompressor)
+ compressor.protect_first_n = 2
+ compressor.protect_last_n = 5
+ compressor.tail_token_budget = 20_000
+ compressor.context_length = 200_000
+ compressor.threshold_percent = 0.80
+ compressor.threshold_tokens = 160_000
+ compressor.max_summary_tokens = 10_000
+ compressor.quiet_mode = True
+ compressor.compression_count = 0
+ compressor.last_prompt_tokens = 0
+ compressor._previous_summary = None
+ compressor._ineffective_compression_count = 0
+ compressor._verify_compaction_cleared_threshold = False
+ compressor._summary_failure_cooldown_until = 0.0
+ compressor.summary_model = None
+ compressor.model = "test-model"
+ compressor.provider = "test"
+ compressor.base_url = "http://localhost"
+ compressor.api_key = ""
+ compressor.api_mode = "chat_completions"
+ return compressor
+
+
+def _summary_response(content="## Goal\nCompaction complete."):
+ response = MagicMock()
+ response.choices = [MagicMock()]
+ response.choices[0].message.content = content
+ return response
+
+
+def test_memory_context_injected_into_initial_summary_prompt_with_focus():
+ compressor = _make_compressor()
+ turns = [
+ {"role": "user", "content": "Fix the auth bug"},
+ {"role": "assistant", "content": "Fixed the JWT expiry check."},
+ ]
+ prompts = []
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response()
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(
+ turns,
+ focus_topic="authentication",
+ memory_context="User uses JWT tokens with a one-hour expiry.",
+ )
+
+ assert len(prompts) == 1
+ assert "MEMORY PROVIDER CONTEXT" in prompts[0]
+ assert "User uses JWT tokens with a one-hour expiry." in prompts[0]
+ assert 'FOCUS TOPIC: "authentication"' in prompts[0]
+
+
+def test_memory_context_injected_into_iterative_summary_prompt():
+ compressor = _make_compressor()
+ compressor._previous_summary = "Previous checkpoint."
+ turns = [
+ {"role": "user", "content": "Continue the migration"},
+ {"role": "assistant", "content": "Migration continued."},
+ ]
+ prompts = []
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response("## Goal\nMigration updated.")
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(
+ turns,
+ memory_context="Checkpoint id: ctx-123",
+ )
+
+ assert len(prompts) == 1
+ assert "PREVIOUS SUMMARY:\nPrevious checkpoint." in prompts[0]
+ assert "MEMORY PROVIDER CONTEXT" in prompts[0]
+ assert "Checkpoint id: ctx-123" in prompts[0]
+
+
+def test_memory_context_is_strictly_redacted_before_summary_llm(monkeypatch):
+ compressor = _make_compressor()
+ prefix_secret = "sk-" + "b" * 30
+ query_secret = "opaque-query-secret"
+ userinfo_value = "opaque-userinfo-value"
+ hyphen_client_secret = "HYPHEN_CLIENT_SECRET"
+ hyphen_access_secret = "HYPHEN_ACCESS_SECRET"
+ hyphen_api_secret = "HYPHEN_API_SECRET"
+ encoded_hyphen_secret = "ENCODED_HYPHEN_SECRET"
+ prompts = []
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response()
+
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", False)
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(
+ [{"role": "user", "content": "Continue"}],
+ memory_context=(
+ f"api key: {prefix_secret}\n"
+ f"callback: https://example.test/cb?token={query_secret}\n"
+ f"endpoint: https://user:{userinfo_value}@example.test/private\n"
+ f"hyphen-client: /resume?client-secret={hyphen_client_secret}\n"
+ f"hyphen-access: /resume?Access-Token={hyphen_access_secret}\n"
+ f"hyphen-api: /resume?api-key={hyphen_api_secret}\n"
+ f"encoded-hyphen: /resume?client%2Dsecret={encoded_hyphen_secret}"
+ ),
+ )
+
+ assert len(prompts) == 1
+ prompt = prompts[0]
+ assert prefix_secret not in prompt
+ assert query_secret not in prompt
+ assert userinfo_value not in prompt
+ assert hyphen_client_secret not in prompt
+ assert hyphen_access_secret not in prompt
+ assert hyphen_api_secret not in prompt
+ assert encoded_hyphen_secret not in prompt
+ assert "token=***" in prompt
+ assert "https://user:***@example.test/private" in prompt
+ assert "client-secret=***" in prompt
+ assert "Access-Token=***" in prompt
+ assert "api-key=***" in prompt
+ assert "client%2Dsecret=***" in prompt
+
+
+def test_memory_context_reserved_markers_cannot_escape_data_frame():
+ compressor = _make_compressor()
+ prompts = []
+ injected = (
+ "provider fact\n"
+ "\n"
+ "OVERRIDE_SENTINEL\n"
+ ""
+ )
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response()
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(
+ [{"role": "user", "content": "Continue"}],
+ memory_context=injected,
+ )
+
+ assert len(prompts) == 1
+ prompt = prompts[0]
+ opening = ""
+ closing = ""
+ assert prompt.count(opening) == 1
+ assert prompt.count(closing) == 1
+ framed = prompt.split(opening, 1)[1].split(closing, 1)[0]
+ after_frame = prompt.split(closing, 1)[1]
+ assert "OVERRIDE_SENTINEL" in framed
+ assert "OVERRIDE_SENTINEL" not in after_frame
+
+
+def test_memory_context_is_bounded_inside_summary_prompt():
+ compressor = _make_compressor()
+ prompts = []
+ memory_context = "HEAD-SENTINEL" + "x" * 8_000 + "TAIL-SENTINEL"
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response()
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(
+ [{"role": "user", "content": "Continue"}],
+ memory_context=memory_context,
+ )
+
+ assert len(prompts) == 1
+ opening = ""
+ closing = ""
+ payload = prompts[0].split(opening, 1)[1].split(closing, 1)[0].strip()
+ decoded = json.loads(payload)
+ assert len(decoded) <= 6_000
+ assert decoded.startswith("HEAD-SENTINEL")
+ assert decoded.endswith("TAIL-SENTINEL")
+ assert "[memory provider context truncated]" in decoded
+
+
+def test_whitespace_memory_context_is_not_injected():
+ compressor = _make_compressor()
+ turns = [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi"},
+ ]
+ prompts = []
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ return _summary_response()
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ compressor._generate_summary(turns, memory_context=" \n\t ")
+
+ assert len(prompts) == 1
+ assert "MEMORY PROVIDER CONTEXT" not in prompts[0]
+
+
+@pytest.mark.parametrize(
+ "error_message",
+ ["auxiliary provider failed", "model_not_found"],
+)
+def test_memory_context_survives_summary_model_retry(error_message):
+ compressor = _make_compressor()
+ compressor.summary_model = "aux/model"
+ compressor._summary_model_fallen_back = False
+ turns = [
+ {"role": "user", "content": "Remember this"},
+ {"role": "assistant", "content": "Noted."},
+ ]
+ prompts = []
+
+ def mock_call_llm(**kwargs):
+ prompts.append(kwargs["messages"][0]["content"])
+ if len(prompts) == 1:
+ raise RuntimeError(error_message)
+ return _summary_response()
+
+ with patch("agent.context_compressor.call_llm", mock_call_llm):
+ result = compressor._generate_summary(
+ turns,
+ memory_context="Checkpoint id: ctx-retry",
+ )
+
+ assert result is not None
+ assert len(prompts) == 2
+ assert all("Checkpoint id: ctx-retry" in prompt for prompt in prompts)
+
+
+def test_compress_passes_memory_context_with_auto_focus():
+ compressor = _make_compressor()
+ received_kwargs = {}
+
+ def tracking_generate(_turns, **kwargs):
+ received_kwargs.update(kwargs)
+ return "## Goal\nTest."
+
+ compressor._generate_summary = tracking_generate
+ messages = [
+ {"role": "system", "content": "System prompt"},
+ {"role": "user", "content": "first"},
+ {"role": "assistant", "content": "reply1"},
+ {"role": "user", "content": "second"},
+ {"role": "assistant", "content": "reply2"},
+ {"role": "user", "content": "third"},
+ {"role": "assistant", "content": "reply3"},
+ {"role": "user", "content": "fourth"},
+ {"role": "assistant", "content": "reply4"},
+ ]
+
+ compressor.compress(
+ messages,
+ current_tokens=100_000,
+ memory_context="Checkpoint id: ctx-auto-focus",
+ )
+
+ assert received_kwargs["memory_context"] == "Checkpoint id: ctx-auto-focus"
+ assert received_kwargs["focus_topic"].startswith("Recent user focus:")
diff --git a/tests/agent/test_reasoning_stale_timeout_floor.py b/tests/agent/test_reasoning_stale_timeout_floor.py
index d17ed4d01352..ec05cb54dd60 100644
--- a/tests/agent/test_reasoning_stale_timeout_floor.py
+++ b/tests/agent/test_reasoning_stale_timeout_floor.py
@@ -77,6 +77,7 @@ import pytest
# xAI Grok reasoning variants — explicit, not bare `grok`.
("x-ai/grok-4-fast-reasoning", 300.0),
("x-ai/grok-4.20-reasoning", 300.0),
+ ("x-ai/grok-4.5", 300.0),
("x-ai/grok-4-fast-non-reasoning", 180.0),
])
def test_reasoning_stale_timeout_floor_positive_cases(model, expected):
diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py
index 57956a383b0e..066834b634ff 100644
--- a/tests/agent/test_redact.py
+++ b/tests/agent/test_redact.py
@@ -588,6 +588,57 @@ class TestWebUrlsNotRedacted:
assert "dbpass" not in result
+class TestStrictUrlCredentialRedaction:
+ @pytest.mark.parametrize(
+ ("text", "secret", "expected"),
+ [
+ (
+ "https://x.test/#access_token=FRAG_SECRET&view=public",
+ "FRAG_SECRET",
+ "https://x.test/#access_token=***&view=public",
+ ),
+ (
+ "/resume?token=REL_SECRET&view=public",
+ "REL_SECRET",
+ "/resume?token=***&view=public",
+ ),
+ (
+ "https://x.test/cb?client%5Fsecret=ENC_SECRET&view=public",
+ "ENC_SECRET",
+ "https://x.test/cb?client%5Fsecret=***&view=public",
+ ),
+ (
+ "https://x.test/cb?client%255Fsecret=DOUBLE_SECRET&view=public",
+ "DOUBLE_SECRET",
+ "https://x.test/cb?client%255Fsecret=***&view=public",
+ ),
+ (
+ "/resume?token=SEMICOLON_SECRET;view=public",
+ "SEMICOLON_SECRET",
+ "/resume?token=***;view=public",
+ ),
+ (
+ "//user:NET_SECRET@x.test/path",
+ "NET_SECRET",
+ "//user:***@x.test/path",
+ ),
+ ],
+ )
+ def test_masks_all_url_reference_forms_only_when_opted_in(
+ self, text, secret, expected
+ ):
+ assert redact_sensitive_text(text) == text
+
+ result = redact_sensitive_text(text, redact_url_credentials=True)
+
+ assert secret not in result
+ assert result == expected
+
+ def test_similarly_named_public_params_remain_unchanged(self):
+ text = "/metrics?token_count=17&session_id=public"
+ assert redact_sensitive_text(text, redact_url_credentials=True) == text
+
+
class TestBareTokenUserinfoRedaction:
"""Regression tests for #6396 — a bare credential in URL userinfo
(``scheme://TOKEN@host``, no ``user:pass`` colon) is redacted. This is the
diff --git a/tests/agent/test_turn_finalizer_iteration_limit_exit.py b/tests/agent/test_turn_finalizer_iteration_limit_exit.py
index 3d1d342f5f40..f1920634b779 100644
--- a/tests/agent/test_turn_finalizer_iteration_limit_exit.py
+++ b/tests/agent/test_turn_finalizer_iteration_limit_exit.py
@@ -296,3 +296,84 @@ def test_pending_response_records_kanban_timeout(monkeypatch):
end_run=True,
event_payload_extra={"budget_used": 60, "budget_max": 60},
)
+
+
+def test_published_pending_candidate_is_not_duplicated_by_finalizer(monkeypatch):
+ """When budget exhaustion preserves a verification candidate that is
+ already the tail assistant message, the finalizer must NOT append a
+ duplicate. The content-comparison guard prevents this. (#65919 §7)
+ """
+ monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
+ agent = _LimitAgent()
+ report = "the composed report"
+
+ result = finalize_turn(
+ agent,
+ final_response=report,
+ api_call_count=60,
+ interrupted=False,
+ failed=False,
+ # The candidate is already in messages as the tail assistant.
+ messages=[
+ {"role": "user", "content": "task"},
+ {"role": "assistant", "content": report},
+ ],
+ conversation_history=[],
+ effective_task_id="task",
+ turn_id="turn",
+ user_message="task",
+ original_user_message="task",
+ _should_review_memory=False,
+ _turn_exit_reason="unknown",
+ _pending_verification_response=report,
+ )
+
+ # The tail assistant already matches final_response — no duplicate appended.
+ roles = [m["role"] for m in result["messages"]]
+ assert roles == ["user", "assistant"]
+ # Persisted messages should also have no duplicate.
+ assert agent.persisted_messages is not None
+ persisted_roles = [m["role"] for m in agent.persisted_messages]
+ assert persisted_roles == ["user", "assistant"]
+
+
+def test_terminal_verification_failure_is_persisted_as_one_correction(monkeypatch):
+ """When verification fails terminally (nudge present but budget exhausted),
+ the finalizer drops the synthetic nudge and the assistant candidate
+ persists as a single correction. No duplicate assistant appended. (#65919 §7)
+ """
+ monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
+ agent = _LimitAgent()
+ report = "terminal failure correction"
+
+ result = finalize_turn(
+ agent,
+ final_response=report,
+ api_call_count=60,
+ interrupted=False,
+ failed=False,
+ messages=[
+ {"role": "user", "content": "task"},
+ {"role": "assistant", "content": report},
+ # Synthetic nudge — should be dropped by _drop_verification_continuation_scaffolding.
+ {"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True},
+ ],
+ conversation_history=[],
+ effective_task_id="task",
+ turn_id="turn",
+ user_message="task",
+ original_user_message="task",
+ _should_review_memory=False,
+ _turn_exit_reason="unknown",
+ _pending_verification_response=report,
+ )
+
+ # The nudge is dropped; the assistant candidate is the tail and matches
+ # final_response, so no duplicate is appended.
+ roles = [m["role"] for m in result["messages"]]
+ assert roles == ["user", "assistant"]
+ # The nudge is gone from persisted messages too.
+ assert agent.persisted_messages is not None
+ persisted_contents = [m.get("content") for m in agent.persisted_messages]
+ assert "[System: run tests]" not in persisted_contents
+ assert report in persisted_contents
diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py
index 04aa4ec4c9cf..a1f853272bb4 100644
--- a/tests/agent/test_usage_pricing.py
+++ b/tests/agent/test_usage_pricing.py
@@ -348,17 +348,58 @@ def test_bedrock_claude_rows_all_carry_cache_pricing():
assert entry.cache_write_cost_per_million > entry.input_cost_per_million, key
+def test_bedrock_current_gen_claude_rows_resolve():
+ """Current-gen Claude models (Opus 4.8/4.7, Sonnet 5) must have Bedrock
+ pricing rows so cached sessions report a dollar cost, not ``unknown``.
+ Assert each resolves via the bare id and a cross-region inference profile
+ (us./global. prefix), that every id for a given model resolves to the same
+ entry, and that the row carries the cache fields a Bedrock Claude session
+ needs.
+
+ (Version-suffixed IDs like ``...-v1:0`` are covered separately by the
+ normalizer test in the suffix-strip change; this test intentionally sticks
+ to id shapes that resolve on ``main`` so it is independent of that PR.)
+ """
+ url = "https://bedrock-runtime.us-east-1.amazonaws.com"
+ for bare in (
+ "anthropic.claude-opus-4-8",
+ "anthropic.claude-opus-4-7",
+ "anthropic.claude-sonnet-5",
+ ):
+ ref = get_pricing_entry(bare, provider="bedrock", base_url=url)
+ assert ref is not None, bare
+ assert ref.input_cost_per_million is not None, bare
+ assert ref.output_cost_per_million is not None, bare
+ # Output costs more than input across the Claude line; sanity-check the
+ # row isn't malformed (input < output).
+ assert ref.output_cost_per_million > ref.input_cost_per_million, bare
+ # Cache fields present so cached sessions price correctly (the #50295
+ # symptom was unknown cost on cached Bedrock Claude sessions).
+ assert ref.cache_read_cost_per_million is not None, bare
+ assert ref.cache_write_cost_per_million is not None, bare
+ # Cross-region inference profiles resolve to the same entry.
+ for mid in (f"us.{bare}", f"global.{bare}"):
+ entry = get_pricing_entry(mid, provider="bedrock", base_url=url)
+ assert entry is not None, mid
+ assert entry.input_cost_per_million == ref.input_cost_per_million, mid
+ assert entry.output_cost_per_million == ref.output_cost_per_million, mid
+
+
def test_bedrock_cross_region_profile_prefix_resolves_to_pricing():
- """Cross-region inference profiles (us./global./eu. prefixes) must resolve
- to the same pricing entry as the bare foundation-model id. Without prefix
- normalization, ``us.anthropic.claude-*`` sessions price as unknown.
+ """Cross-region inference profiles must resolve to the same pricing entry
+ as the bare foundation-model id. Without prefix normalization a scoped
+ ``.anthropic.claude-*`` session prices as unknown.
+
+ Asia-Pacific (``apac.``) and Australia (``au.``) are included because AWS
+ uses the full ``apac.`` prefix, not ``ap.`` — a bare ``ap.`` never matches
+ an ``apac.*`` id, so those geographies previously priced as unknown.
"""
bedrock_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
bare = get_pricing_entry(
"anthropic.claude-sonnet-4-5", provider="bedrock", base_url=bedrock_url
)
assert bare is not None
- for prefix in ("us.", "global.", "eu."):
+ for prefix in ("us.", "global.", "eu.", "apac.", "au."):
scoped = get_pricing_entry(
f"{prefix}anthropic.claude-sonnet-4-5",
provider="bedrock",
@@ -369,6 +410,61 @@ def test_bedrock_cross_region_profile_prefix_resolves_to_pricing():
assert scoped.cache_read_cost_per_million == bare.cache_read_cost_per_million
+def test_bedrock_versioned_inference_profile_resolves_to_bare_pricing():
+ """Bedrock profile IDs may include the provider's dated version suffix.
+
+ The pricing table intentionally uses shorter model-family IDs, so the
+ lookup needs a longest-prefix fallback after stripping the region scope.
+ """
+ bare = get_pricing_entry("anthropic.claude-sonnet-4-6", provider="bedrock")
+ assert bare is not None
+
+ for model in (
+ "us.anthropic.claude-sonnet-4-6-20250514-v1:0",
+ "global.anthropic.claude-sonnet-4-6-20250514-v1:0",
+ ):
+ scoped = get_pricing_entry(model, provider="bedrock")
+ assert scoped is not None, model
+ assert scoped.input_cost_per_million == bare.input_cost_per_million
+ assert scoped.output_cost_per_million == bare.output_cost_per_million
+ assert scoped.cache_read_cost_per_million == bare.cache_read_cost_per_million
+ assert scoped.cache_write_cost_per_million == bare.cache_write_cost_per_million
+
+
+def test_bedrock_pricing_supports_less_common_inference_profile_prefixes():
+ """AWS also exposes profile scopes beyond us./global./eu.; those should
+ not silently fall through to unknown pricing.
+ """
+ bare = get_pricing_entry("anthropic.claude-haiku-4-5", provider="bedrock")
+ entry = get_pricing_entry(
+ "apac.anthropic.claude-haiku-4-5-20251001-v1:0",
+ provider="bedrock",
+ )
+
+ assert bare is not None
+ assert entry is not None
+ for field in (
+ "input_cost_per_million",
+ "output_cost_per_million",
+ "cache_read_cost_per_million",
+ "cache_write_cost_per_million",
+ ):
+ assert getattr(entry, field) == getattr(bare, field)
+
+
+def test_bedrock_unknown_model_continuation_does_not_use_base_pricing():
+ """Unrecognized Bedrock SKUs must remain unknown rather than inheriting a
+ similarly named model family's price.
+ """
+ assert (
+ get_pricing_entry(
+ "anthropic.claude-sonnet-4-6-experimental",
+ provider="bedrock",
+ )
+ is None
+ )
+
+
def test_bedrock_claude_cached_session_estimates_cost_not_unknown():
"""A Bedrock Claude session with cache hits must produce a dollar estimate,
not ``unknown`` — the user-visible symptom in #50295.
diff --git a/tests/agent/test_verification_evidence.py b/tests/agent/test_verification_evidence.py
index 5f957f54efbe..c176f37b9fc2 100644
--- a/tests/agent/test_verification_evidence.py
+++ b/tests/agent/test_verification_evidence.py
@@ -391,3 +391,36 @@ def test_recording_expires_old_edit_only_state(tmp_path, monkeypatch):
status = verification_status(session_id="old-session", cwd=tmp_path)
assert status["status"] == "unverified"
assert status["changed_paths"] == []
+
+
+def test_windows_backslash_ad_hoc_script_path_is_matched(tmp_path, monkeypatch):
+ """Ad-hoc verification scripts with Windows backslash paths must be
+ matched by ``_find_ad_hoc_match`` trying ``posix=False`` in addition to
+ the default ``posix=True``. (#53553 / #65919)
+
+ On Linux, ``Path`` doesn't parse Windows backslash paths, so we mock
+ ``_is_temp_script_path`` to simulate the Windows environment where the
+ path resolves correctly. The test verifies the posix=False splitting
+ fallback — the actual fix from #53553.
+ """
+ from agent.verification_evidence import _find_ad_hoc_match
+
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
+ (tmp_path / "package.json").write_text("{}", encoding="utf-8")
+
+ # On Windows, shlex.split(posix=True) eats backslashes as escape chars;
+ # posix=False preserves them. Mock _is_temp_script_path so the test
+ # focuses on the splitting fallback without needing a real Windows FS.
+ def mock_is_temp_script(token, root):
+ return "hermes-ad-hoc" in token and ".py" in token
+
+ monkeypatch.setattr(
+ "agent.verification_evidence._is_temp_script_path",
+ mock_is_temp_script,
+ )
+
+ win_script = r"C:\Users\test\AppData\Local\Temp\hermes-ad-hoc-check.py"
+ result = _find_ad_hoc_match(f"python {win_script}", tmp_path)
+ assert result is not None, (
+ "Windows backslash path should be matched via posix=False fallback"
+ )
diff --git a/tests/agent/test_verification_stop_caching.py b/tests/agent/test_verification_stop_caching.py
index 41fee3b33747..7620d9eb5170 100644
--- a/tests/agent/test_verification_stop_caching.py
+++ b/tests/agent/test_verification_stop_caching.py
@@ -1,15 +1,15 @@
"""Verification-loop synthetic scaffolding must never reach durable session state.
-verify_on_stop / pre_verify append a synthetic assistant "done" plus a synthetic
-user nudge to keep the agent going one more turn before it can claim completion.
-These messages exist only to drive the loop; persisting them poisons the resumed
-transcript and breaks prompt-prefix cache reuse on later turns (#55733).
+verify_on_stop / pre_verify inject a synthetic user nudge to keep the agent
+going one more turn before it can claim completion. The assistant response is
+real content that persists and is emitted to the UI as an interim message.
+Only the nudge (the synthetic user message) is flagged, so only the nudge
+gets stripped from the durable transcript. This test file verifies:
-Both persistence sinks (SQLite flush + JSON snapshot) route through the single
-``_is_ephemeral_scaffolding`` chokepoint, which is driven by
-``_EPHEMERAL_SCAFFOLDING_FLAGS``. These tests assert that the verification-loop
-flags are registered there and that both sinks drop the flagged messages while
-keeping the real conversation.
+ - The verification-loop flags remain registered in
+ ``_EPHEMERAL_SCAFFOLDING_FLAGS`` (so nudges are stripped).
+ - The DB flush drops only the nudge, keeping the assistant candidate.
+ - The JSON log drops only the nudge, keeping the assistant candidate.
"""
import json
@@ -34,15 +34,16 @@ def test_verification_flags_registered_as_ephemeral(tmp_path, monkeypatch):
assert "_verification_stop_synthetic" in ra._EPHEMERAL_SCAFFOLDING_FLAGS
assert "_pre_verify_synthetic" in ra._EPHEMERAL_SCAFFOLDING_FLAGS
- # The central classifier drives both persistence sinks.
- assert ra._is_ephemeral_scaffolding(
- {"role": "assistant", "content": "done", "_verification_stop_synthetic": True}
- )
+ # The nudge messages ARE scaffolding (they carry the synthetic flag).
assert ra._is_ephemeral_scaffolding(
{"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True}
)
- # Real messages are not scaffolding.
+ assert ra._is_ephemeral_scaffolding(
+ {"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True}
+ )
+ # Real messages (including the assistant candidate) are not.
assert not ra._is_ephemeral_scaffolding({"role": "user", "content": "hi"})
+ assert not ra._is_ephemeral_scaffolding({"role": "assistant", "content": "premature done"})
def _make_agent(ra, session_id, tmp_path):
@@ -64,14 +65,18 @@ def _make_agent(ra, session_id, tmp_path):
return agent
-def test_db_flush_drops_verification_scaffolding(tmp_path, monkeypatch):
+def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
+ """The assistant candidate is NOT flagged synthetic, so it persists.
+ Only the nudge (flagged synthetic) is dropped from the DB flush."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
ra = _fresh_run_agent(tmp_path)
agent = _make_agent(ra, "sess_db", tmp_path)
messages = [
{"role": "user", "content": "hi"},
- {"role": "assistant", "content": "premature done", "_verification_stop_synthetic": True},
+ # Assistant candidate — NOT flagged synthetic, persists.
+ {"role": "assistant", "content": "premature done"},
+ # Nudge — flagged synthetic, gets dropped.
{"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True},
{"role": "assistant", "content": "verified and clean"},
]
@@ -84,18 +89,24 @@ def test_db_flush_drops_verification_scaffolding(tmp_path, monkeypatch):
]
assert "hi" in persisted
assert "verified and clean" in persisted
- assert "premature done" not in persisted
+ # The assistant candidate persists — it is real content.
+ assert "premature done" in persisted
+ # Only the nudge is dropped.
assert "[System: run tests]" not in persisted
-def test_json_log_drops_verification_scaffolding(tmp_path, monkeypatch):
+def test_json_log_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
+ """The assistant candidate is NOT flagged synthetic, so it persists in the
+ JSON log. Only the nudge (flagged synthetic) is dropped."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
ra = _fresh_run_agent(tmp_path)
agent = _make_agent(ra, "sess_json", tmp_path)
messages = [
{"role": "user", "content": "hi"},
- {"role": "assistant", "content": "premature done", "_pre_verify_synthetic": True},
+ # Assistant candidate — NOT flagged synthetic, persists.
+ {"role": "assistant", "content": "premature done"},
+ # Nudge — flagged synthetic, gets dropped.
{"role": "user", "content": "[System: run tests]", "_pre_verify_synthetic": True},
{"role": "assistant", "content": "verified and clean"},
]
@@ -106,5 +117,10 @@ def test_json_log_drops_verification_scaffolding(tmp_path, monkeypatch):
assert log_file.exists()
data = json.loads(log_file.read_text(encoding="utf-8"))
contents = [m.get("content") for m in data["messages"]]
- assert contents == ["hi", "verified and clean"]
+ # The assistant candidate persists — it is real content.
+ assert "premature done" in contents
+ assert "verified and clean" in contents
+ assert "hi" in contents
+ # Only the nudge is dropped.
+ assert "[System: run tests]" not in contents
assert all(not m.get("_pre_verify_synthetic") for m in data["messages"])
diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py
index cf245a2f3266..fd549e114114 100644
--- a/tests/agent/transports/test_chat_completions.py
+++ b/tests/agent/transports/test_chat_completions.py
@@ -747,6 +747,28 @@ class TestChatCompletionsKimi:
)
assert kw["tools"][0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
+ def test_moonshot_outgoing_schema_carries_required_array(self, transport):
+ """Moonshot 400s on object schemas without an explicit `required` array
+ (#66835). Assert the wire-level tool schema — what actually leaves the
+ transport — carries `required: []` on a zero-required-param tool."""
+ tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": "browser_snapshot",
+ "description": "Snapshot",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ },
+ ]
+ kw = transport.build_kwargs(
+ model="moonshotai/kimi-k3",
+ messages=[{"role": "user", "content": "Hi"}],
+ tools=tools,
+ max_tokens_param_fn=lambda n: {"max_tokens": n},
+ )
+ assert kw["tools"][0]["function"]["parameters"]["required"] == []
+
def test_non_moonshot_tools_are_not_mutated(self, transport):
"""Other models don't go through the Moonshot sanitizer."""
original_params = {
diff --git a/tests/cli/test_cli_goal_interrupt.py b/tests/cli/test_cli_goal_interrupt.py
index 6ab4ce89d2cb..7e6c05e6ebd0 100644
--- a/tests/cli/test_cli_goal_interrupt.py
+++ b/tests/cli/test_cli_goal_interrupt.py
@@ -169,7 +169,7 @@ class TestHealthyTurnStillRuns:
# Force the judge to say "continue" without touching the network.
with patch(
"hermes_cli.goals.judge_goal",
- return_value=("continue", "needs more steps", False, None),
+ return_value=("continue", "needs more steps", False, None, False),
):
cli._maybe_continue_goal_after_turn()
@@ -189,7 +189,7 @@ class TestHealthyTurnStillRuns:
with patch(
"hermes_cli.goals.judge_goal",
- return_value=("done", "goal satisfied", False, None),
+ return_value=("done", "goal satisfied", False, None, False),
):
cli._maybe_continue_goal_after_turn()
diff --git a/tests/cli/test_cli_mcp_config_watch.py b/tests/cli/test_cli_mcp_config_watch.py
index 067ecc4cff76..921e54080839 100644
--- a/tests/cli/test_cli_mcp_config_watch.py
+++ b/tests/cli/test_cli_mcp_config_watch.py
@@ -4,11 +4,14 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
-def _make_cli(tmp_path, mcp_servers=None):
+def _make_cli(tmp_path, mcp_servers=None, extra_config=None):
"""Create a minimal HermesCLI instance with mocked config."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
- obj.config = {"mcp_servers": mcp_servers or {}}
+ cfg = {"mcp_servers": mcp_servers or {}}
+ if extra_config:
+ cfg.update(extra_config)
+ obj.config = cfg
obj._agent_running = False
obj._last_config_check = 0.0
obj._config_mcp_servers = mcp_servers or {}
@@ -101,3 +104,132 @@ class TestMCPConfigWatch:
obj._check_config_mcp_changes() # should not raise
obj._reload_mcp.assert_not_called()
+
+ def test_optout_disables_auto_reload(self, tmp_path, capsys):
+ """When mcp.auto_reload_on_config_change is False, a changed
+ mcp_servers section must NOT trigger an automatic reload — but the
+ change is still detected and the user is told how to apply it.
+
+ This protects the provider prompt cache: every automatic reload
+ rebuilds the agent tool surface and invalidates cached prefixes.
+
+ The toggle is the top-level ``mcp:`` section in config.yaml, and the
+ watcher reads it from the same freshly-parsed file it diffs — so
+ flipping the toggle and editing mcp_servers in one edit behaves
+ correctly.
+ """
+ import yaml
+ obj, cfg_file = _make_cli(
+ tmp_path,
+ mcp_servers={},
+ )
+
+ # Simulate a changed mcp_servers section with auto-reload opted out.
+ cfg_file.write_text(yaml.dump({
+ "mcp": {"auto_reload_on_config_change": False},
+ "mcp_servers": {"github": {"url": "https://mcp.github.com"}},
+ }))
+ obj._config_mtime = 0.0 # force stale mtime
+
+ with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
+ obj._check_config_mcp_changes()
+
+ obj._reload_mcp.assert_not_called()
+
+ out = capsys.readouterr().out
+ assert "reload skipped" in out
+ assert "/reload-mcp" in out
+ assert "prompt cache" in out
+
+ def test_optout_updates_snapshot_so_reload_mcp_applies_cleanly(self, tmp_path):
+ """After an opted-out change, the watcher must not re-notify every
+ tick: the snapshot is updated so the same content compares equal on
+ the next pass."""
+ import yaml
+ obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
+
+ cfg_file.write_text(yaml.dump({
+ "mcp": {"auto_reload_on_config_change": False},
+ "mcp_servers": {"github": {"url": "https://mcp.github.com"}},
+ }))
+ obj._config_mtime = 0.0
+
+ with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
+ obj._check_config_mcp_changes()
+ # Second pass: same file content, new mtime — no reload, no change.
+ obj._last_config_check = 0.0
+ obj._config_mtime = 0.0
+ obj._check_config_mcp_changes()
+
+ obj._reload_mcp.assert_not_called()
+ assert obj._config_mcp_servers == {"github": {"url": "https://mcp.github.com"}}
+
+ def test_optout_path_is_top_level_mcp_not_auxiliary(self, tmp_path):
+ """Regression guard: the opt-out toggle is the top-level
+ ``mcp.auto_reload_on_config_change`` key, NOT ``auxiliary.mcp``
+ (which holds side-LLM task provider settings).
+
+ A config that sets ONLY ``auxiliary.mcp.auto_reload_on_config_change:
+ false`` must NOT disable the reload."""
+ import yaml
+ obj, cfg_file = _make_cli(
+ tmp_path,
+ mcp_servers={},
+ )
+
+ cfg_file.write_text(yaml.dump({
+ "auxiliary": {"mcp": {"auto_reload_on_config_change": False}},
+ "mcp_servers": {"github": {"url": "https://mcp.github.com"}},
+ }))
+ obj._config_mtime = 0.0
+
+ with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
+ obj._check_config_mcp_changes()
+
+ # Reload happened because the aux-task path is not the toggle.
+ obj._reload_mcp.assert_called()
+
+ def test_env_var_templates_do_not_false_positive_on_unrelated_saves(
+ self, tmp_path, monkeypatch, capsys
+ ):
+ """Regression for the '/reasoning triggers MCP reload' bug (#55701).
+
+ Init snapshots mcp_servers from the loaded config, which has been
+ through _expand_env_vars() — so ``${MCP_GH_API_KEY}`` is stored
+ expanded. The watcher re-parses the RAW yaml. Without expanding the
+ watcher side too, the comparison is always unequal whenever any
+ template is in use, so EVERY config.yaml rewrite (e.g.
+ save_config_value('agent.reasoning_effort', ...) from /reasoning)
+ fired a full MCP reconnect.
+ """
+ import yaml
+ monkeypatch.setenv("MCP_GH_API_KEY", "sekrit-token")
+
+ raw_servers = {
+ "github": {
+ "url": "https://mcp.github.com",
+ "headers": {"Authorization": "Bearer ${MCP_GH_API_KEY}"},
+ }
+ }
+ expanded_servers = {
+ "github": {
+ "url": "https://mcp.github.com",
+ "headers": {"Authorization": "Bearer sekrit-token"},
+ }
+ }
+ # Init snapshot holds the EXPANDED form (as load_cli_config produces).
+ obj, cfg_file = _make_cli(tmp_path, mcp_servers=expanded_servers)
+
+ # Unrelated-key save: mcp_servers content identical (raw templates),
+ # only reasoning_effort changed — mtime moves.
+ cfg_file.write_text(yaml.dump({
+ "agent": {"reasoning_effort": "high"},
+ "mcp_servers": raw_servers,
+ }))
+ obj._config_mtime = 0.0
+
+ with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
+ obj._check_config_mcp_changes()
+
+ obj._reload_mcp.assert_not_called()
+ assert "MCP server config changed" not in capsys.readouterr().out
diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py
index 7745737c4541..5cd0cfc71c68 100644
--- a/tests/cli/test_fast_command.py
+++ b/tests/cli/test_fast_command.py
@@ -83,6 +83,20 @@ class TestHandleFastCommand(unittest.TestCase):
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
+ # Session-scoped by default: no config write.
+ mock_save.assert_not_called()
+ self.assertIsNone(stub.service_tier)
+ self.assertIsNone(stub.agent)
+
+ def test_global_flag_persists_service_tier(self):
+ cli_mod = _import_cli()
+ stub = self._make_cli(service_tier="priority")
+ with (
+ patch.object(cli_mod, "_cprint"),
+ patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
+ ):
+ cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal --global")
+
mock_save.assert_called_once_with("agent.service_tier", "normal")
self.assertIsNone(stub.service_tier)
self.assertIsNone(stub.agent)
diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py
index 5bd0c4cc7c92..3b66680b5403 100644
--- a/tests/cli/test_reasoning_command.py
+++ b/tests/cli/test_reasoning_command.py
@@ -154,6 +154,129 @@ class TestHandleReasoningCommand(unittest.TestCase):
level = rc.get("effort", "medium")
self.assertEqual(level, "xhigh")
+ def test_effort_defaults_to_session_only(self):
+ """Plain /reasoning is session-scoped — no config write."""
+ from hermes_cli.cli_commands_mixin import CLICommandsMixin
+
+ stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"})
+ with patch("cli.save_config_value") as save_config, patch("cli._cprint"):
+ CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high")
+
+ save_config.assert_not_called()
+ self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
+ self.assertIsNone(stub.agent)
+
+ def test_effort_global_flag_persists_config(self):
+ """--global opts into persisting the effort to config.yaml."""
+ from cli import CLI_CONFIG
+ from hermes_cli.cli_commands_mixin import CLICommandsMixin
+
+ stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"})
+ with patch.dict(CLI_CONFIG.setdefault("agent", {}), {"reasoning_effort": "medium"}), \
+ patch("cli.save_config_value", return_value=True) as save_config, \
+ patch("cli._cprint"):
+ CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high --global")
+ self.assertEqual(CLI_CONFIG["agent"]["reasoning_effort"], "high")
+
+ save_config.assert_called_once_with("agent.reasoning_effort", "high")
+ self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
+ self.assertIsNone(stub.agent)
+
+ def test_effort_session_flag_does_not_persist_config(self):
+ """--session (explicit no-op alias for the default) stays session-only."""
+ from hermes_cli.cli_commands_mixin import CLICommandsMixin
+
+ stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"})
+ with patch("cli.save_config_value") as save_config, patch("cli._cprint"):
+ CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high --session")
+
+ save_config.assert_not_called()
+ self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
+ self.assertIsNone(stub.agent)
+
+ def test_new_session_clears_session_reasoning_override(self):
+ """/new and /clear must not carry a session-only effort override forward."""
+ from cli import CLI_CONFIG, HermesCLI
+
+ agent = SimpleNamespace(
+ reasoning_config={"enabled": True, "effort": "high"},
+ reset_session_state=MagicMock(),
+ )
+ stub = SimpleNamespace(
+ agent=agent,
+ conversation_history=[],
+ session_id="old-session",
+ _session_db=None,
+ _pending_title=None,
+ _resumed=False,
+ reasoning_config={"enabled": True, "effort": "high"},
+ _notify_session_boundary=MagicMock(),
+ )
+
+ with patch.dict(CLI_CONFIG.setdefault("agent", {}), {"reasoning_effort": "medium"}):
+ HermesCLI.new_session(stub, silent=True)
+
+ self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "medium"})
+ self.assertEqual(agent.reasoning_config, {"enabled": True, "effort": "medium"})
+ agent.reset_session_state.assert_called_once()
+
+ def test_new_session_resets_service_tier_and_model_from_config(self):
+ """/new re-derives service tier and model from config.yaml — session
+ /fast and /model switches do not carry forward (#48055, #23131)."""
+ from cli import CLI_CONFIG, HermesCLI
+
+ agent = SimpleNamespace(
+ reasoning_config=None,
+ reset_session_state=MagicMock(),
+ switch_model=MagicMock(),
+ )
+ stub = SimpleNamespace(
+ agent=agent,
+ conversation_history=[],
+ session_id="old-session",
+ _session_db=None,
+ _pending_title=None,
+ _resumed=False,
+ reasoning_config=None,
+ _notify_session_boundary=MagicMock(),
+ # Session had switched to fast + a session-only model.
+ service_tier="priority",
+ _pending_one_turn_model_restore={"model": "stale"},
+ model="session-switched-model",
+ provider="openrouter",
+ requested_provider="openrouter",
+ api_key="k",
+ base_url="",
+ api_mode="",
+ )
+
+ fake_result = SimpleNamespace(
+ success=True,
+ new_model="config-default-model",
+ target_provider="openrouter",
+ api_key="k2",
+ base_url="https://openrouter.ai/api/v1",
+ api_mode="chat_completions",
+ )
+ with patch.dict(
+ CLI_CONFIG.setdefault("agent", {}),
+ {"reasoning_effort": "medium", "service_tier": "normal"},
+ ), patch.dict(
+ CLI_CONFIG,
+ {"model": {"default": "config-default-model", "provider": "openrouter"}},
+ ), patch(
+ "hermes_cli.model_switch.switch_model", return_value=fake_result
+ ):
+ HermesCLI.new_session(stub, silent=True)
+
+ # Fast override cleared back to config default (normal → None).
+ self.assertIsNone(stub.service_tier)
+ # One-turn restore snapshot cleared.
+ self.assertIsNone(stub._pending_one_turn_model_restore)
+ # Model reset to the config default via the live agent swap.
+ self.assertEqual(stub.model, "config-default-model")
+ agent.switch_model.assert_called_once()
+
# ---------------------------------------------------------------------------
# Reasoning extraction and result dict
diff --git a/tests/cron/test_codex_execution_paths.py b/tests/cron/test_codex_execution_paths.py
index 5c3e5cf060b9..8513ce695a14 100644
--- a/tests/cron/test_codex_execution_paths.py
+++ b/tests/cron/test_codex_execution_paths.py
@@ -98,7 +98,7 @@ def test_cron_run_job_codex_path_handles_internal_401_refresh(monkeypatch):
monkeypatch.setattr(run_agent, "AIAgent", _Codex401ThenSuccessAgent)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
- lambda requested=None: {
+ lambda requested=None, **kwargs: {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py
index 23fa89ddde17..23a42ce99b14 100644
--- a/tests/cron/test_cron_provider_pin.py
+++ b/tests/cron/test_cron_provider_pin.py
@@ -334,3 +334,42 @@ class TestModelDriftGuard:
)
assert agent_constructed is True
assert success is True
+
+
+class TestRuntimeResolutionTargetModel:
+ """run_job must resolve the primary provider against the model the job
+ will actually run (per-job pin > env > config default), so providers with
+ model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for
+ the pinned model instead of the stale persisted default."""
+
+ def test_primary_resolution_passes_effective_model(self, tmp_path):
+ job = _base_job(model="my-pinned-model", provider="openrouter")
+ captured = {}
+
+ def _capture(**kwargs):
+ captured.update(kwargs)
+ return {
+ "api_key": "test-key",
+ "base_url": "https://example.invalid/v1",
+ "provider": "openrouter",
+ "api_mode": "chat_completions",
+ }
+
+ fake_db = MagicMock()
+ with patch("cron.scheduler._hermes_home", tmp_path), \
+ patch("cron.scheduler._resolve_origin", return_value=None), \
+ patch("hermes_cli.env_loader.load_hermes_dotenv"), \
+ patch("hermes_cli.env_loader.reset_secret_source_cache"), \
+ patch("hermes_state.SessionDB", return_value=fake_db), \
+ patch(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ side_effect=_capture,
+ ), \
+ patch("run_agent.AIAgent") as mock_agent_cls:
+ mock_agent = MagicMock()
+ mock_agent.run_conversation.return_value = {"final_response": "ok"}
+ mock_agent_cls.return_value = mock_agent
+ run_job(job)
+
+ assert captured.get("target_model") == "my-pinned-model"
+ assert captured.get("requested") == "openrouter"
diff --git a/tests/gateway/test_25107_stale_base_url_api_mode.py b/tests/gateway/test_25107_stale_base_url_api_mode.py
index 781fc3c4bf68..9cbe06217488 100644
--- a/tests/gateway/test_25107_stale_base_url_api_mode.py
+++ b/tests/gateway/test_25107_stale_base_url_api_mode.py
@@ -186,7 +186,7 @@ async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path,
adapter = _FakePickerAdapter()
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))
- confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
+ confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model --global"))
assert confirmation is not None
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py
index eb4878273366..eb68b9eeeeff 100644
--- a/tests/gateway/test_choice_picker.py
+++ b/tests/gateway/test_choice_picker.py
@@ -189,7 +189,8 @@ class TestFastChoicePicker:
assert values == ["fast", "normal"]
@pytest.mark.asyncio
- async def test_fast_picker_selection_persists_service_tier(self, tmp_path, monkeypatch):
+ async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypatch):
+ """A bare /fast picker tap applies a session override, not a config write."""
self._patch_fast_support(monkeypatch, tmp_path)
adapter = _PickerAdapter()
runner = _make_runner(adapter)
@@ -199,6 +200,22 @@ class TestFastChoicePicker:
on_choice = adapter.calls[0]["on_choice_selected"]
await on_choice(event.source.chat_id, "fast")
+ assert runner._service_tier == "priority"
+ assert runner._session_service_tier_overrides
+ assert not (tmp_path / "config.yaml").exists()
+
+ @pytest.mark.asyncio
+ async def test_fast_picker_global_flag_persists_service_tier(self, tmp_path, monkeypatch):
+ """A /fast --global picker tap persists agent.service_tier to config."""
+ self._patch_fast_support(monkeypatch, tmp_path)
+ adapter = _PickerAdapter()
+ runner = _make_runner(adapter)
+ event = _make_event("/fast --global")
+
+ await runner._handle_fast_command(event)
+ on_choice = adapter.calls[0]["on_choice_selected"]
+ await on_choice(event.source.chat_id, "fast")
+
assert runner._service_tier == "priority"
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert saved["agent"]["service_tier"] == "fast"
diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py
index 3422ebcf08e5..fae172bb3275 100644
--- a/tests/gateway/test_config.py
+++ b/tests/gateway/test_config.py
@@ -2,6 +2,7 @@
import logging
import os
+from pathlib import Path
from unittest.mock import patch
import pytest
@@ -486,6 +487,72 @@ class TestGatewayConfigRoundtrip:
class TestLoadGatewayConfig:
+ def test_shipped_template_does_not_enable_auto_reset(self, tmp_path, monkeypatch):
+ """A fresh install seeded from cli-config.yaml.example must not
+ auto-reset sessions.
+
+ Installers (scripts/install.sh, scripts/install.ps1,
+ docker/stage2-hook.sh, hermes doctor) copy the template verbatim to
+ ~/.hermes/config.yaml, so whatever ``session_reset.mode`` the template
+ ships becomes an EXPLICIT user setting that overrides the code
+ default. After #60194 flipped the default to "none", the template
+ still said "both" — every new install kept 24h-idle resets on
+ (Luciano's report, July 2026). This pins the invariant: template
+ seed == no auto-reset.
+ """
+ template = (
+ Path(__file__).resolve().parents[2] / "cli-config.yaml.example"
+ )
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ template.read_text(encoding="utf-8"), encoding="utf-8"
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.default_reset_policy.mode == "none"
+
+ def test_no_config_yaml_means_no_auto_reset(self, tmp_path, monkeypatch):
+ """With no config.yaml at all, sessions must never auto-reset."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.default_reset_policy.mode == "none"
+
+ def test_session_reset_without_mode_means_no_auto_reset(self, tmp_path, monkeypatch):
+ """A session_reset block that tunes knobs but omits mode stays off."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "session_reset:\n idle_minutes: 60\n", encoding="utf-8"
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.default_reset_policy.mode == "none"
+ assert config.default_reset_policy.idle_minutes == 60
+
+ def test_explicit_session_reset_opt_in_is_honored(self, tmp_path, monkeypatch):
+ """Users who explicitly opt in to auto-reset keep their policy."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text(
+ "session_reset:\n mode: idle\n idle_minutes: 30\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.default_reset_policy.mode == "idle"
+ assert config.default_reset_policy.idle_minutes == 30
+
def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
@@ -595,6 +662,213 @@ class TestLoadGatewayConfig:
assert extra["websocket_heartbeat_ack_max_age_seconds"] == 75
assert extra["websocket_max_latency_seconds"] == 30
+ def test_session_reset_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ """``gateway.session_reset`` (nested form) must reach default_reset_policy,
+ mirroring the gateway.multiplex_profiles precedent."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n session_reset:\n mode: idle\n idle_minutes: 30\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.default_reset_policy.mode == "idle"
+ assert config.default_reset_policy.idle_minutes == 30
+
+ def test_quick_commands_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n quick_commands:\n limits:\n type: exec\n command: echo ok\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
+
+ def test_stt_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ """Asserts False (not the True default) so the test fails if the
+ nested gateway.stt value never reaches from_dict() and silently
+ falls back to the class default instead."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n stt:\n enabled: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.stt_enabled is False
+
+ def test_stt_echo_transcripts_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n stt_echo_transcripts: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.stt_echo_transcripts is False
+
+ def test_group_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n group_sessions_per_user: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.group_sessions_per_user is False
+
+ def test_thread_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n thread_sessions_per_user: true\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.thread_sessions_per_user is True
+
+ def test_reset_triggers_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n reset_triggers:\n - /new\n - /clear\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.reset_triggers == ["/new", "/clear"]
+
+ def test_always_log_local_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n always_log_local: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.always_log_local is False
+
+ def test_filter_silence_narration_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n filter_silence_narration: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.filter_silence_narration is False
+
+ def test_unauthorized_dm_behavior_from_nested_gateway_section(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n unauthorized_dm_behavior: ignore\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.unauthorized_dm_behavior == "ignore"
+
+ def test_top_level_still_wins_over_nested_gateway_section(self, tmp_path, monkeypatch):
+ """Top-level keys keep precedence over the nested gateway.* fallback
+ for every key this fix touches (matches the existing
+ gateway.streaming/write_sessions_json precedence contract)."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "always_log_local: true\n"
+ "gateway:\n"
+ " always_log_local: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ assert config.always_log_local is True
+
+ def test_present_empty_top_level_session_reset_blocks_nested_fallback(self, tmp_path, monkeypatch):
+ """Key-presence precedence: a present (even empty) top-level
+ session_reset must NOT be replaced by gateway.session_reset —
+ the fallback fires only when the top-level key is absent."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "session_reset: {}\n"
+ "gateway:\n"
+ " session_reset:\n"
+ " mode: idle\n"
+ " idle_minutes: 30\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ # The nested value must not leak through the present top-level key.
+ assert config.default_reset_policy.mode != "idle"
+
+ def test_present_top_level_stt_blocks_nested_fallback(self, tmp_path, monkeypatch):
+ """Key-presence precedence for stt: a present top-level stt (even
+ mistyped/non-dict) must not be replaced by gateway.stt."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "stt: {}\n"
+ "gateway:\n"
+ " stt:\n"
+ " enabled: false\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ config = load_gateway_config()
+
+ # gateway.stt.enabled=false must NOT win over the present top-level stt.
+ assert config.stt_enabled is True
+
def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch):
"""GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so
start_gateway()'s connect loop actually dials the connector. Registering
@@ -860,6 +1134,164 @@ class TestLoadGatewayConfig:
]
assert os.environ.get("DISCORD_ALLOWED_USERS") == "123456789012345678"
+ def test_bridges_nested_gateway_platforms_dingtalk_allowed_users_to_env(self, tmp_path, monkeypatch):
+ """gateway.platforms.dingtalk.extra.allowed_users must reach
+ DINGTALK_ALLOWED_USERS — it's the documented config.yaml alternative
+ to the env var (website/docs/user-guide/messaging/dingtalk.md), the
+ adapter reads it from PlatformConfig.extra, but gateway auth
+ (_is_user_authorized) only consults the env var.
+ """
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n"
+ " platforms:\n"
+ " dingtalk:\n"
+ " enabled: true\n"
+ " extra:\n"
+ " allowed_users:\n"
+ " - user-id-1\n"
+ " - user-id-2\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("DINGTALK_ALLOWED_USERS", raising=False)
+
+ config = load_gateway_config()
+
+ assert config.platforms[Platform.DINGTALK].extra["allowed_users"] == [
+ "user-id-1",
+ "user-id-2",
+ ]
+ assert os.environ.get("DINGTALK_ALLOWED_USERS") == "user-id-1,user-id-2"
+
+ def test_bridges_platforms_dingtalk_extra_allowed_users_to_env(self, tmp_path, monkeypatch):
+ """platforms.dingtalk.extra.allowed_users should reach DINGTALK_ALLOWED_USERS too."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "platforms:\n"
+ " dingtalk:\n"
+ " extra:\n"
+ " allowed_users:\n"
+ " - manager1234\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("DINGTALK_ALLOWED_USERS", raising=False)
+
+ config = load_gateway_config()
+
+ assert config.platforms[Platform.DINGTALK].extra["allowed_users"] == [
+ "manager1234",
+ ]
+ assert os.environ.get("DINGTALK_ALLOWED_USERS") == "manager1234"
+
+ def test_dingtalk_allowed_users_env_takes_precedence_over_config_yaml(self, tmp_path, monkeypatch):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n"
+ " platforms:\n"
+ " dingtalk:\n"
+ " extra:\n"
+ " allowed_users:\n"
+ " - config-user\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("DINGTALK_ALLOWED_USERS", "env-user")
+
+ load_gateway_config()
+
+ assert os.environ.get("DINGTALK_ALLOWED_USERS") == "env-user"
+
+ def test_top_level_dingtalk_allowed_users_wins_over_nested_extra(self, tmp_path, monkeypatch):
+ """The legacy top-level dingtalk: block keeps precedence over the
+ nested platform extra when both define an allowlist."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "dingtalk:\n"
+ " allowed_users:\n"
+ " - top-level-user\n"
+ "gateway:\n"
+ " platforms:\n"
+ " dingtalk:\n"
+ " extra:\n"
+ " allowed_users:\n"
+ " - nested-user\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("DINGTALK_ALLOWED_USERS", raising=False)
+
+ load_gateway_config()
+
+ assert os.environ.get("DINGTALK_ALLOWED_USERS") == "top-level-user"
+
+ def test_nested_dingtalk_allowlist_authorizes_listed_user_only(self, tmp_path, monkeypatch):
+ """E2E for the documented setup: a nested-only allowlist must
+ authorize the listed user at the gateway and still deny others.
+
+ Before the bridge existed, the listed user passed the adapter's
+ _is_user_allowed() but _is_user_authorized() fell through to
+ default-deny because DINGTALK_ALLOWED_USERS was never populated.
+ """
+ from types import SimpleNamespace
+
+ from gateway.run import GatewayRunner
+ from gateway.session import SessionSource
+
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n"
+ " platforms:\n"
+ " dingtalk:\n"
+ " enabled: true\n"
+ " extra:\n"
+ " allowed_users:\n"
+ " - user-id-1\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ for var in (
+ "DINGTALK_ALLOWED_USERS",
+ "DINGTALK_ALLOW_ALL_USERS",
+ "GATEWAY_ALLOWED_USERS",
+ "GATEWAY_ALLOW_ALL_USERS",
+ ):
+ monkeypatch.delenv(var, raising=False)
+
+ config = load_gateway_config()
+
+ runner = object.__new__(GatewayRunner)
+ runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
+ runner.config = config
+
+ def _dm_source(user_id):
+ return SessionSource(
+ platform=Platform.DINGTALK,
+ chat_id="dm-1",
+ chat_type="dm",
+ user_id=user_id,
+ user_name="someone",
+ )
+
+ assert runner._is_user_authorized(_dm_source("user-id-1")) is True
+ assert runner._is_user_authorized(_dm_source("intruder")) is False
+
def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
diff --git a/tests/gateway/test_delivery_ledger.py b/tests/gateway/test_delivery_ledger.py
index 855da10937f0..fcde3a07349e 100644
--- a/tests/gateway/test_delivery_ledger.py
+++ b/tests/gateway/test_delivery_ledger.py
@@ -281,3 +281,124 @@ class TestGatewayRedeliverySweep:
n = await runner._redeliver_pending_obligations()
assert n == 0
adapter.send.assert_not_awaited()
+
+
+class TestAttemptsOnlySpentOnRealSends:
+ """``attempts`` is the redelivery budget — it must buy a send.
+
+ ``self.adapters`` only holds a platform after its ``connect()`` succeeded,
+ and the sweep claimed every dead-owner row regardless. A platform that
+ failed to connect this boot therefore burned one attempt per boot while
+ the caller's ``adapter is None`` branch skipped it without sending — so
+ after MAX_ATTEMPTS boots the row abandoned having never been sent once,
+ losing exactly the response the ledger exists to guarantee. That failure
+ correlates with the crash that created the obligation: the network
+ trouble that killed the send tends to still be there on the next boot.
+ """
+
+ def test_absent_platform_does_not_burn_attempts(self):
+ _record(platform="telegram")
+ dl.mark_attempting("ob-1")
+
+ for _ in range(dl.MAX_ATTEMPTS + 2):
+ _orphan("ob-1")
+ assert dl.sweep_recoverable(deliverable_platforms={"discord"}) == []
+
+ row = dl.debug_rows()
+ assert "abandoned" not in row
+ with dl._connect() as conn:
+ state, attempts = conn.execute(
+ "SELECT state, attempts FROM delivery_obligations "
+ "WHERE obligation_id=?", ("ob-1",),
+ ).fetchone()
+ assert attempts == 0, "an unsendable boot must not spend the budget"
+ assert state == "attempting"
+
+ def test_row_still_delivers_once_its_platform_returns(self):
+ _record(platform="telegram")
+ for _ in range(dl.MAX_ATTEMPTS + 2):
+ _orphan("ob-1")
+ dl.sweep_recoverable(deliverable_platforms={"discord"})
+
+ _orphan("ob-1")
+ claimed = dl.sweep_recoverable(deliverable_platforms={"telegram"})
+ assert len(claimed) == 1
+ assert claimed[0]["attempts"] == 1
+
+ def test_present_platform_still_claims(self):
+ _record(platform="slack")
+ _orphan("ob-1")
+ claimed = dl.sweep_recoverable(deliverable_platforms={"slack"})
+ assert len(claimed) == 1
+
+ def test_omitting_the_filter_claims_everything(self):
+ """Back-compat: existing callers pass no platform set."""
+ _record(platform="telegram")
+ _orphan("ob-1")
+ assert len(dl.sweep_recoverable()) == 1
+
+ def test_stale_rows_abandon_even_when_undeliverable(self):
+ """The cutoff still bounds rows whose platform never returns."""
+ _record(platform="telegram")
+ _orphan("ob-1")
+ future = time.time() + dl.STALE_AFTER_SECONDS + 10
+ assert dl.sweep_recoverable(
+ now=future, deliverable_platforms={"discord"}
+ ) == []
+ with dl._connect() as conn:
+ state = conn.execute(
+ "SELECT state FROM delivery_obligations WHERE obligation_id=?",
+ ("ob-1",),
+ ).fetchone()[0]
+ assert state == "abandoned"
+
+
+class TestUnconnectedPlatformKeepsItsBudget:
+ """End-to-end through the real runner: boots where the platform failed to
+ connect must not consume the row's redelivery budget."""
+
+ @staticmethod
+ def _runner_without_slack():
+ from gateway.run import GatewayRunner
+
+ runner = object.__new__(GatewayRunner)
+ runner.adapters = {} # slack failed to connect this boot
+ _store = MagicMock()
+ _store.clear_resume_pending = AsyncMock()
+ _store._store = None
+ runner.session_store = None
+ runner._async_session_store = _store
+ return runner
+
+ @pytest.mark.asyncio
+ async def test_row_survives_boots_where_its_platform_is_down(self):
+ _record(platform="slack")
+ dl.mark_attempting("ob-1")
+
+ for _ in range(dl.MAX_ATTEMPTS + 1):
+ _orphan("ob-1")
+ runner = self._runner_without_slack()
+ assert await runner._redeliver_pending_obligations() == 0
+
+ assert _row("ob-1")["state"] != "abandoned", (
+ "the obligation was abandoned without a single send being attempted"
+ )
+ assert _row("ob-1")["attempts"] == 0
+
+ @pytest.mark.asyncio
+ async def test_delivers_when_the_platform_comes_back(self):
+ from gateway.config import Platform
+
+ _record(platform="slack")
+ for _ in range(dl.MAX_ATTEMPTS + 1):
+ _orphan("ob-1")
+ await self._runner_without_slack()._redeliver_pending_obligations()
+
+ _orphan("ob-1")
+ adapter = MagicMock()
+ adapter.send = AsyncMock(return_value=MagicMock(success=True, error=""))
+ runner = self._runner_without_slack()
+ runner.adapters = {Platform.SLACK: adapter}
+
+ assert await runner._redeliver_pending_obligations() == 1
+ assert _row("ob-1")["state"] == "delivered"
diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py
index a5b07c89880d..3e76b42e3332 100644
--- a/tests/gateway/test_fast_command.py
+++ b/tests/gateway/test_fast_command.py
@@ -143,7 +143,8 @@ def test_turn_route_skips_priority_processing_for_unsupported_models():
@pytest.mark.asyncio
-async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
+async def test_handle_fast_command_session_scoped_by_default(monkeypatch, tmp_path):
+ """Bare /fast fast applies a session override — config.yaml untouched."""
runner = _make_runner()
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
@@ -152,11 +153,57 @@ async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
response = await runner._handle_fast_command(_make_event("/fast fast"))
+ assert "FAST" in response
+ assert runner._service_tier == "priority"
+ # Session override recorded; config.yaml NOT written.
+ assert runner._session_service_tier_overrides
+ assert not (tmp_path / "config.yaml").exists()
+
+
+@pytest.mark.asyncio
+async def test_handle_fast_command_global_flag_persists_config(monkeypatch, tmp_path):
+ runner = _make_runner()
+
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+ monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
+ monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
+
+ response = await runner._handle_fast_command(_make_event("/fast fast --global"))
+
assert "FAST" in response
assert runner._service_tier == "priority"
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert saved["agent"]["service_tier"] == "fast"
+ # Global write supersedes the session override.
+ assert not runner._session_service_tier_overrides
+
+
+@pytest.mark.asyncio
+async def test_session_fast_override_beats_config_default(monkeypatch, tmp_path):
+ """A session /fast normal wins over agent.service_tier: fast in config."""
+ runner = _make_runner()
+
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+ monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
+ monkeypatch.setattr(
+ gateway_run,
+ "_load_gateway_runtime_config",
+ lambda: {"agent": {"service_tier": "fast"}},
+ )
+ monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
+
+ event = _make_event("/fast normal")
+ session_key = runner._session_key_for_source(event.source)
+
+ response = await runner._handle_fast_command(event)
+
+ assert "NORMAL" in response
+ # Override stores explicit None (normal) and wins over config "fast".
+ assert session_key in runner._session_service_tier_overrides
+ assert runner._resolve_session_service_tier(session_key=session_key) is None
+ # A different session still gets the config default.
+ assert runner._resolve_session_service_tier(session_key="other-session") == "priority"
@pytest.mark.asyncio
diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py
index 0d63659bc13e..bf972f7ada9f 100644
--- a/tests/gateway/test_feishu.py
+++ b/tests/gateway/test_feishu.py
@@ -2690,6 +2690,127 @@ class TestAdapterBehavior(unittest.TestCase):
[[{"tag": "md", "text": "---\n1. 第一项\n 2. 子项\n- 外层\n - 内层\n下划线 和 ~~删除线~~"}]],
)
+ @patch.dict(os.environ, {}, clear=True)
+ def test_build_outbound_payload_uses_post_for_markdown_table(self):
+ from gateway.config import PlatformConfig
+ from plugins.platforms.feishu.adapter import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ content = "| Name | Score |\n| --- | ---: |\n| Ada | 10 |"
+
+ msg_type, raw_payload = adapter._build_outbound_payload(content)
+
+ self.assertEqual(msg_type, "post")
+ payload = json.loads(raw_payload)
+ self.assertEqual(
+ payload["zh_cn"]["content"],
+ [[{"tag": "md", "text": content}]],
+ )
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_uses_post_for_every_chunk_of_multi_chunk_markdown(self):
+ """Regression for #26841: when a long Markdown message is split
+ across multiple chunks, every chunk must go out as
+ ``msg_type=post`` — including chunk 1. The bug was that the
+ first chunk often had only plain prose (the per-chunk regex
+ didn't match) and was sent as ``text``, so users saw literal
+ ``**bold``/``## heading``/code fences while later chunks
+ rendered correctly.
+ """
+ from gateway.config import PlatformConfig
+ from plugins.platforms.feishu.adapter import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = []
+
+ class _MessageAPI:
+ def create(self, request):
+ captured.append(request)
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(
+ message_id=f"om_chunk_{len(captured)}",
+ ),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ # Force a deterministic split so the test doesn't depend on the
+ # exact 8000-char limit. Chunk 1 is plain prose; chunk 2 has
+ # the markdown markers. Without the fix, chunk 1 went out as
+ # ``msg_type=text``.
+ first_chunk = "Here is a short intro that has no markdown markers at all."
+ second_chunk = "## Heading\nAnd then some **bold** text."
+
+ with patch.object(
+ adapter, "truncate_message", return_value=[first_chunk, second_chunk],
+ ), patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content=first_chunk + "\n" + second_chunk,
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(len(captured), 2)
+ msg_types = [r.request_body.msg_type for r in captured]
+ self.assertEqual(msg_types, ["post", "post"])
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_send_plain_text_message_not_upgraded_by_prefer_post(self):
+ """A message with no markdown at all must still go out as plain
+ ``msg_type=text`` — the whole-message ``prefer_post`` decision
+ only flips on when the formatted message matches the hint regex.
+ """
+ from gateway.config import PlatformConfig
+ from plugins.platforms.feishu.adapter import FeishuAdapter
+
+ adapter = FeishuAdapter(PlatformConfig())
+ captured = []
+
+ class _MessageAPI:
+ def create(self, request):
+ captured.append(request)
+ return SimpleNamespace(
+ success=lambda: True,
+ data=SimpleNamespace(
+ message_id=f"om_chunk_{len(captured)}",
+ ),
+ )
+
+ adapter._client = SimpleNamespace(
+ im=SimpleNamespace(
+ v1=SimpleNamespace(
+ message=_MessageAPI(),
+ )
+ )
+ )
+
+ async def _direct(func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
+ result = asyncio.run(
+ adapter.send(
+ chat_id="oc_chat",
+ content="just a plain sentence",
+ )
+ )
+
+ self.assertTrue(result.success)
+ self.assertEqual(len(captured), 1)
+ self.assertEqual(captured[0].request_body.msg_type, "text")
+
@patch.dict(os.environ, {}, clear=True)
def test_send_uses_post_for_inline_markdown(self):
from gateway.config import PlatformConfig
diff --git a/tests/gateway/test_feishu_table_markdown.py b/tests/gateway/test_feishu_table_markdown.py
new file mode 100644
index 000000000000..1ff9cbe3516c
--- /dev/null
+++ b/tests/gateway/test_feishu_table_markdown.py
@@ -0,0 +1,133 @@
+"""Tests for Feishu adapter outbound markdown payload construction.
+
+Reproduces the bug tracked in hermes-agent issue #52786:
+`_build_outbound_payload` was force-downgrading any message containing a
+markdown pipe table to ``msg_type=text``, so Feishu clients rendered the raw
+pipe-and-dash source instead of a table. Empirically current Feishu clients
+render ``post``+``md`` tables natively, so the downgrade branch must be removed.
+
+These tests guard the fix. They invoke the real adapter via the project's
+plugin-loader helper so that no ``sys.path`` / ``sys.modules`` games are
+needed.
+"""
+
+from __future__ import annotations
+
+import json
+
+from tests.gateway._plugin_adapter_loader import load_plugin_adapter
+
+_adapter = load_plugin_adapter("feishu")
+
+
+def _call_build_outbound_payload(content: str) -> tuple[str, str]:
+ """Invoke ``_build_outbound_payload`` on a bare adapter instance.
+
+ ``_build_outbound_payload`` is a method that only uses module-level
+ helpers (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``,
+ ``_build_markdown_post_payload``) and never touches ``self.*``, so a bare
+ object is sufficient.
+ """
+ inst = object.__new__(_adapter.FeishuAdapter)
+ return inst._build_outbound_payload(content)
+
+
+def _md_texts_from_post_payload(payload_str: str) -> list[str]:
+ """Pull every ``{tag:'md', text:'...'}`` element out of a Feishu post payload.
+
+ Real payload shape::
+
+ {"zh_cn": {"content": [[{"tag": "md", "text": "..."}], ...]}}
+
+ Helpers and tests need to introspect the ``md`` blocks regardless of
+ locale, so we walk the structure generically.
+ """
+ payload = json.loads(payload_str)
+ if not isinstance(payload, dict):
+ return []
+ texts: list[str] = []
+ for lang_val in payload.values():
+ if not isinstance(lang_val, dict):
+ continue
+ content = lang_val.get("content", [])
+ if not isinstance(content, list):
+ continue
+ for block in content:
+ if isinstance(block, list):
+ candidates = block
+ else:
+ candidates = [block]
+ for el in candidates:
+ if isinstance(el, dict) and el.get("tag") == "md":
+ texts.append(el.get("text", ""))
+ return texts
+
+
+def test_markdown_table_uses_post_not_text():
+ """Regression test for issue #52786 (and its older sibling #23938).
+
+ A message whose only markdown is a table must take the ``post`` path,
+ not be downgraded to plain text.
+ """
+ content = (
+ "| col A | col B |\n"
+ "| ----- | ----- |\n"
+ "| 1 | 2 |"
+ )
+ msg_type, payload_str = _call_build_outbound_payload(content)
+ assert msg_type == "post", (
+ f"expected 'post' for a markdown table (issue #52786), got {msg_type!r}; "
+ "the table-downgrade branch in _build_outbound_payload has been re-introduced"
+ )
+ md_texts = _md_texts_from_post_payload(payload_str)
+ assert md_texts, f"post payload must include at least one md element; got {payload_str!r}"
+ joined = "".join(md_texts)
+ assert "col A" in joined and "|" in joined, (
+ "table text was lost or reformatted when switching from text to post"
+ )
+
+
+def test_plain_text_without_markdown_still_uses_text():
+ """Negative control: a message with no markdown hints and no table must
+ still go to plain text. Guards against accidentally promoting everything
+ to ``post``."""
+ msg_type, _ = _call_build_outbound_payload("just a plain sentence with no markup")
+ assert msg_type == "text"
+
+
+def test_existing_markdown_heading_still_uses_post():
+ """Sanity: the existing ``post`` path (heading / list / code / bold /
+ link) must still work after the table downgrade is removed."""
+ msg_type, payload_str = _call_build_outbound_payload("# hello world\n")
+ assert msg_type == "post"
+ md_texts = _md_texts_from_post_payload(payload_str)
+ assert md_texts, f"expected at least one md element; got {payload_str!r}"
+ assert any("hello world" in t for t in md_texts), (
+ f"expected 'hello world' in md elements; got {md_texts!r}"
+ )
+
+
+def test_table_combined_with_other_markdown_does_not_downgrade():
+ """A message that mixes a table with surrounding markdown must also
+ take the ``post`` path.
+
+ The old ``_MARKDOWN_TABLE_RE`` branch returned ``text`` unconditionally
+ and stripped all the surrounding markdown formatting, so a Feishu
+ reader saw literal pipes and lost the prose framing the table.
+ """
+ content = (
+ "Here is the data:\n\n"
+ "| col A | col B |\n"
+ "| ----- | ----- |\n"
+ "| 1 | 2 |\n\n"
+ "Let me know."
+ )
+ msg_type, payload_str = _call_build_outbound_payload(content)
+ assert msg_type == "post"
+ md_texts = _md_texts_from_post_payload(payload_str)
+ joined = "\n".join(md_texts)
+ assert "Here is the data" in joined, (
+ "leading prose was lost when downgrading a mixed-table message"
+ )
+ assert "col A" in joined, "table header was lost"
+ assert "Let me know" in joined, "trailing prose was lost"
diff --git a/tests/gateway/test_goal_verdict_send.py b/tests/gateway/test_goal_verdict_send.py
index 535dbe555427..cce636147819 100644
--- a/tests/gateway/test_goal_verdict_send.py
+++ b/tests/gateway/test_goal_verdict_send.py
@@ -107,7 +107,7 @@ async def test_goal_verdict_done_sent_via_adapter_send(hermes_home):
mgr = GoalManager(session_entry.session_id)
mgr.set("ship the feature")
- with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False, None)):
+ with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False, None, False)):
await runner._post_turn_goal_continuation(
session_entry=session_entry,
source=src,
@@ -136,7 +136,7 @@ async def test_goal_verdict_continue_enqueues_continuation(hermes_home):
mgr = GoalManager(session_entry.session_id)
mgr.set("polish the docs")
- with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False, None)):
+ with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False, None, False)):
await runner._post_turn_goal_continuation(
session_entry=session_entry,
source=src,
@@ -164,7 +164,7 @@ async def test_goal_verdict_budget_exhausted_sends_pause(hermes_home):
state.turns_used = 2
save_goal(session_entry.session_id, state)
- with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False, None)):
+ with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False, None, False)):
await runner._post_turn_goal_continuation(
session_entry=session_entry,
source=src,
@@ -211,7 +211,7 @@ async def test_goal_verdict_survives_adapter_without_send(hermes_home):
runner.adapters[Platform.TELEGRAM] = _NoSendAdapter()
- with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False, None)):
+ with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False, None, False)):
# must not raise
await runner._post_turn_goal_continuation(
session_entry=session_entry,
diff --git a/tests/gateway/test_model_command_flat_string_config.py b/tests/gateway/test_model_command_flat_string_config.py
index 8de92e9e57dd..e90b340335a3 100644
--- a/tests/gateway/test_model_command_flat_string_config.py
+++ b/tests/gateway/test_model_command_flat_string_config.py
@@ -159,11 +159,11 @@ async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path,
@pytest.mark.asyncio
-async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):
- """A plain ``/model X`` (no --global) now persists to config.yaml.
+async def test_model_no_flag_is_session_scoped_by_default(tmp_path, monkeypatch):
+ """A plain ``/model X`` (no --global) does NOT persist to config.yaml.
- This is the user-facing fix: switching models in one session survives
- into the next without re-typing the switch every time.
+ This is the user-facing fix: switches are session-scoped unless the user
+ opts in with ``--global`` or sets ``model.persist_switch_by_default: true``.
"""
cfg_path = _setup_isolated_home(
tmp_path,
@@ -178,7 +178,7 @@ async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):
assert result is not None
assert "gpt-5.5" in result
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
- assert written["model"]["default"] == "gpt-5.5"
+ assert written["model"]["default"] == "old-model"
@pytest.mark.asyncio
diff --git a/tests/gateway/test_model_picker_persist.py b/tests/gateway/test_model_picker_persist.py
index ddba56d9084b..4cb5c6e9ad42 100644
--- a/tests/gateway/test_model_picker_persist.py
+++ b/tests/gateway/test_model_picker_persist.py
@@ -8,9 +8,10 @@ model in the Telegram/Discord picker silently reverted on the next launch while
*typing* the same model persisted — a contradiction the same PR introduced.
After the fix (#49176), the picker callback honors the resolved
-``persist_global`` (defaults to ``True``, still respects ``--session``) and runs
-the same read-modify-write block the text path uses, so a tapped model survives
-across sessions like a typed one.
+``persist_global`` and runs the same read-modify-write block the text path
+uses, so a tapped model behaves exactly like a typed one. Since the
+session-scope-by-default change, both default to session-only and persist
+only with ``--global`` (or ``model.persist_switch_by_default: true``).
These tests drive the real ``_handle_model_command`` with a fake picker-capable
adapter that captures the ``on_model_selected`` callback, then invoke that
@@ -176,14 +177,14 @@ async def _drive_picker(runner, event):
],
ids=["nested-dict", "flat-string"],
)
-async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model):
- """Tapping a model in the picker (bare /model) persists to config.yaml,
- matching the typed ``/model`` default — this is the #49176 fix. The written
- ``model:`` must always end up a nested dict regardless of the seed shape."""
+async def test_picker_tap_global_flag_persists(tmp_path, monkeypatch, seed_model):
+ """Tapping a model in a ``/model --global`` picker persists to config.yaml,
+ matching the typed ``/model --global`` path. The written ``model:`` must
+ always end up a nested dict regardless of the seed shape."""
adapter = _FakePickerAdapter()
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, seed_model)
- confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model"))
+ confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model --global"))
assert confirmation is not None
assert "gpt-5.5" in confirmation
@@ -198,6 +199,34 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)
assert "api_mode" not in written["model"]
+@pytest.mark.asyncio
+async def test_picker_tap_is_session_scoped_by_default(tmp_path, monkeypatch):
+ """Tapping a model in a bare ``/model`` picker applies an in-memory session
+ override and does NOT touch config.yaml — switches are session-scoped
+ unless the user opts in with ``--global`` (or sets
+ ``model.persist_switch_by_default: true``)."""
+ adapter = _FakePickerAdapter()
+ cfg_path = _setup_isolated_home(
+ tmp_path, monkeypatch, {"default": "old-model", "provider": "openrouter"}
+ )
+ runner = _make_runner(adapter)
+
+ confirmation = await _drive_picker(runner, _make_event("/model"))
+
+ assert confirmation is not None
+ assert "gpt-5.5" in confirmation
+ # The session override IS applied in-memory (the switch worked).
+ assert runner._session_model_overrides, "session override should be set"
+ assert any(
+ ov.get("model") == "gpt-5.5"
+ for ov in runner._session_model_overrides.values()
+ )
+ # But config.yaml is untouched — session-scoped by default.
+ written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
+ assert written["model"]["default"] == "old-model"
+ assert written["model"]["provider"] == "openrouter"
+
+
@pytest.mark.asyncio
async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch):
"""``/model --session`` then a picker tap stays in-memory only — config
diff --git a/tests/gateway/test_restart_redelivery_dedup.py b/tests/gateway/test_restart_redelivery_dedup.py
index 7b651d9c8016..2729992fb6b6 100644
--- a/tests/gateway/test_restart_redelivery_dedup.py
+++ b/tests/gateway/test_restart_redelivery_dedup.py
@@ -148,6 +148,43 @@ async def test_stale_marker_older_than_5min_does_not_block(tmp_path, monkeypatch
runner.request_restart.assert_called_once()
+@pytest.mark.asyncio
+async def test_slow_service_restart_still_ignores_same_update(tmp_path, monkeypatch):
+ """A slow drain must not outlive dedup when this boot came from /restart.
+
+ Service-managed shutdown can take more than five minutes while in-flight
+ gateway work drains. The replacement process still knows it booted from
+ the recorded chat restart, so the first same update must be suppressed
+ instead of requesting exit 75 again and entering a supervisor loop.
+ """
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+ monkeypatch.setenv("INVOCATION_ID", "systemd-test")
+
+ marker = tmp_path / ".restart_last_processed.json"
+ marker.write_text(
+ json.dumps(
+ {
+ "platform": "telegram",
+ "update_id": 12345,
+ "requested_at": time.time() - 1200,
+ }
+ )
+ )
+
+ runner, _adapter = make_restart_runner()
+ request_restart = MagicMock()
+ monkeypatch.setattr(runner, "request_restart", request_restart)
+ runner._booted_from_restart = True
+
+ result = await runner._handle_restart_command(
+ _make_restart_event(update_id=12345)
+ )
+
+ assert result == ""
+ request_restart.assert_not_called()
+ assert runner._booted_from_restart is False
+
+
@pytest.mark.asyncio
async def test_no_marker_file_allows_restart(tmp_path, monkeypatch):
"""Clean gateway start (no prior marker) processes /restart normally."""
diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py
index 5c151c56ea9a..9892b38b90d3 100644
--- a/tests/gateway/test_run_progress_topics.py
+++ b/tests/gateway/test_run_progress_topics.py
@@ -697,6 +697,48 @@ class QueuedCommentaryAgent:
}
+class QueuedSilenceAgent:
+ """First turn is intentionally silent; queued follow-up still runs."""
+
+ calls = 0
+
+ def __init__(self, **kwargs):
+ self.tools = []
+
+ def run_conversation(self, message, conversation_history=None, task_id=None):
+ type(self).calls += 1
+ return {
+ "final_response": "NO_REPLY" if type(self).calls == 1 else "follow-up processed",
+ "messages": [],
+ "api_calls": 1,
+ }
+
+
+class QueuedFailedEmptyAgent:
+ """First turn fails empty; its normalized error must send before follow-up."""
+
+ calls = 0
+
+ def __init__(self, **kwargs):
+ self.tools = []
+
+ def run_conversation(self, message, conversation_history=None, task_id=None):
+ type(self).calls += 1
+ if type(self).calls == 1:
+ return {
+ "final_response": "",
+ "messages": [],
+ "api_calls": 1,
+ "failed": True,
+ "error": "provider exploded",
+ }
+ return {
+ "final_response": "follow-up processed",
+ "messages": [],
+ "api_calls": 1,
+ }
+
+
class BackgroundReviewAgent:
def __init__(self, **kwargs):
self.background_review_callback = kwargs.get("background_review_callback")
@@ -1090,6 +1132,52 @@ async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monke
assert "final response 1" in sent_texts
+@pytest.mark.asyncio
+async def test_run_agent_suppresses_silent_first_turn_and_processes_queued_followup(
+ monkeypatch, tmp_path,
+):
+ """Regression: queued direct-send must not leak NO_REPLY to the channel."""
+ QueuedSilenceAgent.calls = 0
+ adapter, result = await _run_with_agent(
+ monkeypatch,
+ tmp_path,
+ QueuedSilenceAgent,
+ session_id="sess-queued-silence",
+ pending_text="queued follow-up",
+ platform=Platform.SLACK,
+ chat_id="C123",
+ thread_id="1712345678.000100",
+ )
+
+ sent_texts = [call["content"] for call in adapter.sent]
+ assert QueuedSilenceAgent.calls == 2
+ assert result["final_response"] == "follow-up processed"
+ assert "NO_REPLY" not in sent_texts
+
+
+@pytest.mark.asyncio
+async def test_run_agent_sends_normalized_failure_before_queued_followup(
+ monkeypatch, tmp_path,
+):
+ """Queued delivery uses finalized output, not the raw empty agent result."""
+ QueuedFailedEmptyAgent.calls = 0
+ adapter, result = await _run_with_agent(
+ monkeypatch,
+ tmp_path,
+ QueuedFailedEmptyAgent,
+ session_id="sess-queued-failed-empty",
+ pending_text="queued follow-up",
+ platform=Platform.SLACK,
+ chat_id="C123",
+ thread_id="1712345678.000100",
+ )
+
+ sent_texts = [call["content"] for call in adapter.sent]
+ assert QueuedFailedEmptyAgent.calls == 2
+ assert result["final_response"] == "follow-up processed"
+ assert any("The request failed: provider exploded" in text for text in sent_texts)
+
+
@pytest.mark.asyncio
async def test_run_agent_defers_background_review_notification_until_release(monkeypatch, tmp_path):
adapter, result = await _run_with_agent(
diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py
index b43bacf046a5..430f44da02cb 100644
--- a/tests/gateway/test_stream_consumer.py
+++ b/tests/gateway/test_stream_consumer.py
@@ -2379,3 +2379,40 @@ class TestStripOrphanCloseTags:
assert tag not in consumer._accumulated
assert "trailing prose" in consumer._accumulated
assert "more" in consumer._accumulated
+
+
+class TestHasDeliveredTextAfterSegmentBreak:
+ """has_delivered_text must find a delivered segment after a segment break,
+ but must not claim text from a failed delivery. (#65919 review)"""
+
+ def test_finds_delivered_segment_after_segment_break(self):
+ """A successfully delivered segment must still be found by
+ has_delivered_text after _reset_segment_state runs."""
+ c = _make_consumer()
+ # Simulate a successfully delivered segment
+ c._last_sent_text = "Here is the first segment"
+ c._reset_segment_state()
+ # After the reset, has_delivered_text must still find it
+ assert c.has_delivered_text("Here is the first segment") is True
+
+ def test_does_not_find_undelivered_text(self):
+ """Text that was never delivered must not be claimed."""
+ c = _make_consumer()
+ c._last_sent_text = "delivered text"
+ c._reset_segment_state()
+ assert c.has_delivered_text("never sent text") is False
+
+ def test_finds_commentary_text(self):
+ """has_delivered_text must find commentary text delivered via
+ on_commentary."""
+ c = _make_consumer()
+ c._delivered_commentary_texts.append("interim commentary")
+ assert c.has_delivered_text("interim commentary") is True
+
+ def test_does_not_match_empty(self):
+ """Empty/whitespace text must not match."""
+ c = _make_consumer()
+ c._last_sent_text = "some text"
+ c._reset_segment_state()
+ assert c.has_delivered_text("") is False
+ assert c.has_delivered_text(" ") is False
diff --git a/tests/hermes_cli/test_anthropic_picker_curated.py b/tests/hermes_cli/test_anthropic_picker_curated.py
index 407bb362b1f2..6465a87396a0 100644
--- a/tests/hermes_cli/test_anthropic_picker_curated.py
+++ b/tests/hermes_cli/test_anthropic_picker_curated.py
@@ -20,6 +20,7 @@ def test_anthropic_curated_alias_survives_when_live_omits_it():
"""A curated alias missing from /v1/models still surfaces (first)."""
curated = M._PROVIDER_MODELS["anthropic"]
assert "claude-fable-5" in curated # sanity: the alias is curated
+ assert "claude-sonnet-5" in curated # newest Sonnet alias is curated
# Live catalog the API would actually return — no fable-5.
live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
@@ -27,6 +28,7 @@ def test_anthropic_curated_alias_survives_when_live_omits_it():
result = M.provider_model_ids("anthropic")
assert "claude-fable-5" in result
+ assert "claude-sonnet-5" in result
# Curated order is preserved at the front.
assert result[:len(curated)] == list(curated)
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index 1d9c396fda60..c7e48dab2d37 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -15,6 +15,7 @@ from hermes_cli.config import (
get_compatible_custom_providers,
_explicit_config_paths,
_normalize_max_turns_config,
+ is_provider_enabled,
load_config,
load_env,
migrate_config,
@@ -2240,3 +2241,117 @@ class TestCodexAppServerAutoConfig:
assert raw["compression"]["codex_app_server_auto"] == "hermes"
+class TestIsProviderEnabled:
+ """``is_provider_enabled`` gates ``providers.`` blocks for the
+ model picker, ``/models`` listings and the runtime resolver. Default
+ must be ``True`` so existing configs keep working untouched."""
+
+ def test_missing_flag_defaults_to_enabled(self):
+ assert is_provider_enabled({"name": "Anthropic"}) is True
+
+ def test_empty_block_defaults_to_enabled(self):
+ assert is_provider_enabled({}) is True
+
+ def test_explicit_true_is_enabled(self):
+ assert is_provider_enabled({"enabled": True}) is True
+
+ def test_explicit_false_hides_it(self):
+ assert is_provider_enabled({"enabled": False}) is False
+
+ @pytest.mark.parametrize("raw", ["false", "False", "FALSE", "0", "no", "off"])
+ def test_yaml_string_falsy_values_hide_it(self, raw):
+ # YAML can hand us a string for a value when the user quotes it.
+ assert is_provider_enabled({"enabled": raw}) is False
+
+ @pytest.mark.parametrize("raw", ["true", "True", "yes", "on", "1", "anything-else"])
+ def test_yaml_string_truthy_values_keep_it_enabled(self, raw):
+ assert is_provider_enabled({"enabled": raw}) is True
+
+ def test_non_dict_input_defaults_to_enabled(self):
+ # Malformed entries (None, list, string) don't disappear silently —
+ # the gate stays open and the existing validation paths will flag
+ # them.
+ assert is_provider_enabled(None) is True
+ assert is_provider_enabled([]) is True
+ assert is_provider_enabled("oops") is True
+
+
+class TestProviderEnabledRuntimeGate:
+ """Verify ``resolve_runtime_provider`` honours ``enabled: false`` for
+ both custom-defined and built-in provider names. Smoke test only —
+ full runtime resolution has its own fixture-heavy tests; here we
+ only assert the early-exit raises a typed error."""
+
+ def test_disabled_custom_provider_raises_valueerror(self, tmp_path, monkeypatch):
+ cfg = {
+ "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"},
+ "providers": {
+ "my-fork": {
+ "name": "my-fork",
+ "base_url": "http://127.0.0.1:9999",
+ "api_key": "not-needed",
+ "enabled": False,
+ },
+ },
+ }
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(yaml.safe_dump(cfg))
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ # Bust the in-process config cache so the override picks up.
+ from hermes_cli import config as cfg_mod
+ cfg_mod._cached_config = None # type: ignore[attr-defined]
+
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ with pytest.raises(ValueError, match="disabled"):
+ resolve_runtime_provider(requested="my-fork")
+
+ def test_disabled_builtin_provider_raises_valueerror(self, tmp_path, monkeypatch):
+ # `openrouter` is a built-in name with its own resolution path —
+ # the gate must fire BEFORE that path runs.
+ cfg = {
+ "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"},
+ "providers": {
+ "openrouter": {
+ "name": "OpenRouter",
+ "base_url": "https://openrouter.ai/api/v1",
+ "enabled": False,
+ },
+ },
+ }
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(yaml.safe_dump(cfg))
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ from hermes_cli import config as cfg_mod
+ cfg_mod._cached_config = None # type: ignore[attr-defined]
+
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ with pytest.raises(ValueError, match="disabled"):
+ resolve_runtime_provider(requested="openrouter")
+
+ def test_enabled_provider_does_not_raise(self, tmp_path, monkeypatch):
+ cfg = {
+ "model": {"default": "claude-sonnet-4-6", "provider": "claude-agent-sdk"},
+ "providers": {
+ "claude-agent-sdk": {
+ "name": "Claude Agent SDK",
+ "base_url": "http://127.0.0.1:3456",
+ "api_key": "not-needed",
+ "enabled": True,
+ },
+ },
+ }
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(yaml.safe_dump(cfg))
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ from hermes_cli import config as cfg_mod
+ cfg_mod._cached_config = None # type: ignore[attr-defined]
+
+ # Don't assert success — built-in resolution needs more state.
+ # We only assert this path doesn't hit the disabled-gate.
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ try:
+ resolve_runtime_provider(requested="claude-agent-sdk")
+ except ValueError as e:
+ assert "disabled" not in str(e).lower()
+ except Exception:
+ pass # any non-ValueError is fine; we only gate the disabled path
diff --git a/tests/hermes_cli/test_config_validation.py b/tests/hermes_cli/test_config_validation.py
index bd00a0d301e5..4d0d3df6ed77 100644
--- a/tests/hermes_cli/test_config_validation.py
+++ b/tests/hermes_cli/test_config_validation.py
@@ -214,38 +214,27 @@ class TestConfigIssueDataclass:
class TestUnknownTopLevelKeys:
- """Unknown top-level keys should warn (not error); known roots stay silent."""
+ """Arbitrary top-level keys must NOT warn — they are bridged to os.environ.
- def test_typo_top_level_key_warns_with_key_name(self):
- """A typo like skillz: must surface as a warning naming the key."""
+ Top-level scalars in config.yaml are forwarded into the environment
+ (gateway/run.py, hermes send) so users can feed skills and external apps
+ env-style keys like DISCORD_HOME_CHANNEL or MY_APP_TOKEN. A closed-world
+ allowlist can never enumerate those, so no "Unknown top-level config key"
+ warning may exist.
+ """
+
+ def test_arbitrary_top_level_keys_stay_silent(self):
+ """Env-style and custom keys must produce no unknown-key warnings."""
issues = validate_config_structure({
"model": {"provider": "openrouter"},
+ "DISCORD_HOME_CHANNEL": "12345",
+ "TELEGRAM_HOME_CHANNEL": "-100987",
+ "DISCORD_ALLOW_ALL_USERS": True,
+ "MY_CUSTOM_SKILL_VAR": "hello",
"skillz": {"enabled": True},
- "secrity": {"redact": True},
})
- warnings = [i for i in issues if i.severity == "warning"]
- unknown = [i for i in warnings if "Unknown top-level config key" in i.message]
- messages = " ".join(i.message for i in unknown)
- assert "skillz" in messages
- assert "secrity" in messages
- assert all(i.severity == "warning" for i in unknown)
- assert not any(i.severity == "error" for i in unknown)
-
- def test_all_default_config_roots_accepted_without_unknown_warning(self):
- """Every DEFAULT_CONFIG root (and legacy extras) must not warn as unknown."""
- config = {key: {} if isinstance(DEFAULT_CONFIG.get(key), dict) else DEFAULT_CONFIG.get(key)
- for key in DEFAULT_CONFIG}
- for key in _EXTRA_KNOWN_ROOT_KEYS:
- if key == "custom_providers":
- config[key] = [{"name": "x", "base_url": "https://example.com"}]
- config.setdefault("model", {"provider": "custom", "default": "m"})
- elif key == "fallback_model":
- config[key] = {"provider": "openrouter", "model": "test"}
- else:
- config[key] = {}
- issues = validate_config_structure(config)
- unknown = [i for i in issues if "Unknown top-level config key" in i.message]
- assert unknown == [], f"Unexpected unknown-key warnings: {[i.message for i in unknown]}"
+ assert not any("Unknown top-level config key" in i.message for i in issues)
+ assert issues == []
def test_known_root_keys_derived_from_default_config(self):
"""_KNOWN_ROOT_KEYS must be DEFAULT_CONFIG.keys() plus extras — single source of truth."""
@@ -254,7 +243,7 @@ class TestUnknownTopLevelKeys:
assert _KNOWN_ROOT_KEYS == frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
def test_provider_like_unknown_root_keeps_misplaced_message(self):
- """Preserve existing base_url/api_key root-level guidance (not generic unknown)."""
+ """Preserve existing base_url/api_key root-level guidance."""
issues = validate_config_structure({
"base_url": "https://example.com/v1",
"api_key": "secret",
@@ -263,19 +252,13 @@ class TestUnknownTopLevelKeys:
i for i in issues
if i.severity == "warning" and "looks misplaced" in i.message
]
- generic_unknown = [
- i for i in issues
- if "Unknown top-level config key" in i.message
- ]
assert any("base_url" in i.message for i in misplaced)
assert any("api_key" in i.message for i in misplaced)
- assert generic_unknown == []
def test_private_underscore_keys_not_flagged(self):
- """Internal keys starting with _ remain ignored (except known defaults)."""
+ """Internal keys starting with _ remain ignored."""
issues = validate_config_structure({
"_internal_scratch": True,
"model": {"provider": "openrouter"},
})
- assert not any("Unknown top-level" in i.message for i in issues)
- assert not any("_internal_scratch" in i.message for i in issues)
+ assert issues == []
diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py
index 5333efd1095a..0c8274bf0f15 100644
--- a/tests/hermes_cli/test_console_engine.py
+++ b/tests/hermes_cli/test_console_engine.py
@@ -404,18 +404,21 @@ def test_help_lists_supported_commands_and_not_full_cli():
def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):
engine = HermesConsoleEngine()
- pending = engine.execute("config set console.test true")
+ # Use a schema-known key path. Since #34067, `config set` refuses unknown
+ # top-level keys, so this flow test must target a valid path (telegram is a
+ # PlatformConfig-shaped dict that accepts arbitrary child keys).
+ pending = engine.execute("config set telegram.test true")
assert pending.status == "confirm_required"
from hermes_cli.config import read_raw_config
assert read_raw_config() == {}
- result = engine.execute("config set console.test true", confirmed=True)
+ result = engine.execute("config set telegram.test true", confirmed=True)
assert result.status == "ok"
- assert "console.test" in result.output
- assert read_raw_config()["console"]["test"] is True
+ assert "telegram.test" in result.output
+ assert read_raw_config()["telegram"]["test"] is True
def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):
diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py
index 7fd71b3c40a3..c06af6f05726 100644
--- a/tests/hermes_cli/test_gateway.py
+++ b/tests/hermes_cli/test_gateway.py
@@ -1,8 +1,12 @@
"""Tests for hermes_cli.gateway."""
import argparse
+import os
+import pty
import signal
+import subprocess
import sys
+import textwrap
from types import ModuleType, SimpleNamespace
import pytest
@@ -78,6 +82,87 @@ def test_run_gateway_exits_nonzero_when_start_gateway_reports_failure(monkeypatc
assert calls == [(True, None)]
+@pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTY coverage")
+@pytest.mark.parametrize(
+ ("stdin_is_tty", "outcome", "expected_exit"),
+ [
+ (True, "systemexit:75", 75),
+ (False, "systemexit:75", 75),
+ (False, "systemexit:78", 78),
+ (False, "failure", 1),
+ ],
+)
+def test_gateway_run_subprocess_preserves_daemon_exit_codes(
+ tmp_path, stdin_is_tty, outcome, expected_exit
+):
+ """TTY state must not rewrite the gateway's process-level exit contract.
+
+ Exit 75 is the intentional systemd/launchd restart handoff, exit 78 is a
+ fatal configuration error, and a false startup result is a generic failure.
+ In particular, a non-TTY daemon launch must not blanket-catch SystemExit,
+ because doing so would hide genuine startup/configuration failures.
+ """
+ script = textwrap.dedent(
+ """
+ import os
+ import sys
+ import types
+
+ import hermes_cli.gateway as gateway_cli
+
+ outcome = os.environ["HERMES_TEST_GATEWAY_OUTCOME"]
+
+ async def start_gateway(*, replace, verbosity):
+ if outcome == "failure":
+ return False
+ raise SystemExit(int(outcome.split(":", 1)[1]))
+
+ fake_run = types.ModuleType("gateway.run")
+ fake_run.start_gateway = start_gateway
+ sys.modules["gateway.run"] = fake_run
+
+ gateway_cli._guard_official_docker_root_gateway = lambda: None
+ gateway_cli._guard_named_profile_under_multiplexer = lambda force=False: None
+ gateway_cli._guard_supervised_gateway_conflict = lambda force=False: None
+ gateway_cli._guard_existing_gateway_process_conflict = lambda replace=False: None
+ gateway_cli.supports_systemd_services = lambda: False
+ gateway_cli.run_gateway()
+ """
+ )
+ env = {
+ **os.environ,
+ "HERMES_HOME": str(tmp_path),
+ "HERMES_GATEWAY_EXIT_DIAG": "0",
+ "HERMES_TEST_GATEWAY_OUTCOME": outcome,
+ "INVOCATION_ID": "systemd-test",
+ }
+
+ master_fd = slave_fd = None
+ try:
+ if stdin_is_tty:
+ master_fd, slave_fd = pty.openpty()
+ stdin = slave_fd
+ else:
+ stdin = subprocess.DEVNULL
+ completed = subprocess.run(
+ [sys.executable, "-c", script],
+ stdin=stdin,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ timeout=30,
+ check=False,
+ )
+ finally:
+ if slave_fd is not None:
+ os.close(slave_fd)
+ if master_fd is not None:
+ os.close(master_fd)
+
+ assert completed.returncode == expected_exit, completed.stderr
+
+
def test_run_gateway_refuses_root_in_official_docker(monkeypatch, tmp_path, capsys):
project_root = tmp_path / "opt" / "hermes"
(project_root / "docker").mkdir(parents=True)
diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py
index 118c110079cc..5b96ce832ba5 100644
--- a/tests/hermes_cli/test_goals.py
+++ b/tests/hermes_cli/test_goals.py
@@ -152,13 +152,13 @@ class TestJudgeGoal:
def test_empty_goal_skipped(self):
from hermes_cli.goals import judge_goal
- verdict, _, _, _wd = judge_goal("", "some response")
+ verdict, _, _, _wd, _tf = judge_goal("", "some response")
assert verdict == "skipped"
def test_empty_response_continues(self):
from hermes_cli.goals import judge_goal
- verdict, _, _, _wd = judge_goal("ship the thing", "")
+ verdict, _, _, _wd, _tf = judge_goal("ship the thing", "")
assert verdict == "continue"
def test_no_aux_client_continues(self):
@@ -169,7 +169,7 @@ class TestJudgeGoal:
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured"),
):
- verdict, _, _, _wd = goals.judge_goal("my goal", "my response")
+ verdict, _, _, _wd, _tf = goals.judge_goal("my goal", "my response")
assert verdict == "continue"
def test_api_error_continues(self):
@@ -180,7 +180,7 @@ class TestJudgeGoal:
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("boom"),
):
- verdict, reason, _, _wd = goals.judge_goal("goal", "response")
+ verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "response")
assert verdict == "continue"
assert "judge error" in reason.lower()
@@ -193,7 +193,7 @@ class TestJudgeGoal:
choices=[MagicMock(message=MagicMock(content='{"done": true, "reason": "achieved"}'))]
),
):
- verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
+ verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "agent response")
assert verdict == "done"
assert reason == "achieved"
@@ -206,7 +206,7 @@ class TestJudgeGoal:
choices=[MagicMock(message=MagicMock(content='{"done": false, "reason": "not yet"}'))]
),
):
- verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
+ verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "agent response")
assert verdict == "continue"
assert reason == "not yet"
@@ -295,7 +295,7 @@ class TestGoalManager:
mgr = GoalManager(session_id="eval-sid-1")
mgr.set("ship it")
- with patch.object(goals, "judge_goal", return_value=("done", "shipped", False, None)):
+ with patch.object(goals, "judge_goal", return_value=("done", "shipped", False, None, False)):
decision = mgr.evaluate_after_turn("I shipped the feature.")
assert decision["verdict"] == "done"
@@ -311,7 +311,7 @@ class TestGoalManager:
mgr = GoalManager(session_id="eval-sid-2", default_max_turns=5)
mgr.set("a long goal")
- with patch.object(goals, "judge_goal", return_value=("continue", "more work", False, None)):
+ with patch.object(goals, "judge_goal", return_value=("continue", "more work", False, None, False)):
decision = mgr.evaluate_after_turn("made some progress")
assert decision["verdict"] == "continue"
@@ -329,7 +329,7 @@ class TestGoalManager:
mgr = GoalManager(session_id="eval-sid-3", default_max_turns=2)
mgr.set("hard goal")
- with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False, None)):
+ with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False, None, False)):
d1 = mgr.evaluate_after_turn("step 1")
assert d1["should_continue"] is True
assert mgr.state.turns_used == 1
@@ -438,9 +438,12 @@ class TestJudgeParseFailureAutoPause:
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("connection reset"),
):
- verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
+ verdict, _, parse_failed, _wd, transport_failed = goals.judge_goal(
+ "goal", "response"
+ )
assert verdict == "continue"
assert parse_failed is False
+ assert transport_failed is True
def test_empty_judge_reply_flagged_as_parse_failure(self):
"""End-to-end: judge returns empty content → parse_failed=True."""
@@ -450,7 +453,7 @@ class TestJudgeParseFailureAutoPause:
"agent.auxiliary_client.call_llm",
return_value=MagicMock(choices=[MagicMock(message=MagicMock(content=""))]),
):
- verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
+ verdict, _, parse_failed, _wd, _tf = goals.judge_goal("goal", "response")
assert verdict == "continue"
assert parse_failed is True
@@ -464,7 +467,7 @@ class TestJudgeParseFailureAutoPause:
mgr.set("do a thing")
with patch.object(
- goals, "judge_goal", return_value=("continue", "judge returned empty response", True, None)
+ goals, "judge_goal", return_value=("continue", "judge returned empty response", True, None, False)
):
d1 = mgr.evaluate_after_turn("step 1")
assert d1["should_continue"] is True
@@ -493,7 +496,7 @@ class TestJudgeParseFailureAutoPause:
# Two parse failures…
with patch.object(
- goals, "judge_goal", return_value=("continue", "not json", True, None)
+ goals, "judge_goal", return_value=("continue", "not json", True, None, False)
):
mgr.evaluate_after_turn("step 1")
mgr.evaluate_after_turn("step 2")
@@ -501,29 +504,50 @@ class TestJudgeParseFailureAutoPause:
# …then one clean reply resets the counter.
with patch.object(
- goals, "judge_goal", return_value=("continue", "making progress", False, None)
+ goals, "judge_goal", return_value=("continue", "making progress", False, None, False)
):
d = mgr.evaluate_after_turn("step 3")
assert d["should_continue"] is True
assert mgr.state.consecutive_parse_failures == 0
- def test_parse_failure_counter_not_incremented_by_api_errors(self, hermes_home):
- """API/transport errors must NOT count toward the auto-pause threshold."""
+ def test_transport_failures_do_not_increment_parse_counter(self, hermes_home):
+ """Transport failures use their own counter and a good reply resets both."""
from hermes_cli import goals
from hermes_cli.goals import GoalManager
mgr = GoalManager(session_id="parse-fail-sid-3", default_max_turns=20)
mgr.set("goal")
+ assert mgr.state is not None
with patch.object(
- goals, "judge_goal", return_value=("continue", "judge error: RuntimeError", False, None)
+ goals,
+ "judge_goal",
+ return_value=(
+ "continue",
+ "judge error: RuntimeError",
+ False,
+ None,
+ True,
+ ),
):
- for _ in range(5):
+ for _ in range(2):
d = mgr.evaluate_after_turn("still going")
assert d["should_continue"] is True
assert mgr.state.consecutive_parse_failures == 0
+ assert mgr.state.consecutive_transport_failures == 2
assert mgr.state.status == "active"
+ with patch.object(
+ goals,
+ "judge_goal",
+ return_value=("continue", "making progress", False, None, False),
+ ):
+ d = mgr.evaluate_after_turn("recovered")
+
+ assert d["should_continue"] is True
+ assert mgr.state.consecutive_parse_failures == 0
+ assert mgr.state.consecutive_transport_failures == 0
+
def test_consecutive_parse_failures_persists_across_goalmanager_reloads(
self, hermes_home
):
@@ -535,7 +559,7 @@ class TestJudgeParseFailureAutoPause:
mgr.set("persistent goal")
with patch.object(
- goals, "judge_goal", return_value=("continue", "empty", True, None)
+ goals, "judge_goal", return_value=("continue", "empty", True, None, False)
):
mgr.evaluate_after_turn("r")
mgr.evaluate_after_turn("r")
@@ -732,7 +756,7 @@ class TestJudgeGoalWithSubgoals:
return _FakeResp()
with patch("agent.auxiliary_client.call_llm", side_effect=_fake_call_llm):
- verdict, reason, parse_failed, _wd = goals.judge_goal(
+ verdict, reason, parse_failed, _wd, _tf = goals.judge_goal(
"ship the feature",
"ok shipped",
subgoals=["write tests", "update docs"],
@@ -837,7 +861,7 @@ class TestWaitBarrier:
assert mgr.is_waiting() is True
# The judge must NOT be called while parked, and no turn is burned.
- judge = MagicMock(return_value=("continue", "x", False, None))
+ judge = MagicMock(return_value=("continue", "x", False, None, False))
with patch.object(goals, "judge_goal", judge):
decision = mgr.evaluate_after_turn("still waiting on CI")
@@ -869,7 +893,7 @@ class TestWaitBarrier:
assert mgr.is_waiting() is False # lazy auto-clear
assert mgr.state.waiting_on_pid is None
- with patch.object(goals, "judge_goal", return_value=("continue", "more", False, None)):
+ with patch.object(goals, "judge_goal", return_value=("continue", "more", False, None, False)):
decision = mgr.evaluate_after_turn("process finished, here are results")
assert decision["verdict"] == "continue"
@@ -886,7 +910,7 @@ class TestWaitBarrier:
# is_waiting clears the stale barrier immediately.
assert mgr.is_waiting() is False
- with patch.object(goals, "judge_goal", return_value=("continue", "go", False, None)):
+ with patch.object(goals, "judge_goal", return_value=("continue", "go", False, None, False)):
decision = mgr.evaluate_after_turn("response")
assert decision["should_continue"] is True
@@ -986,7 +1010,7 @@ class TestJudgeDrivenWait:
# Judge sees the running process and says wait-on-pid.
with patch.object(
goals, "judge_goal",
- return_value=("wait", "CI watcher still running", False, {"pid": proc.pid}),
+ return_value=("wait", "CI watcher still running", False, {"pid": proc.pid}, False),
):
decision = mgr.evaluate_after_turn(
"Pushed the PR, watching CI.",
@@ -1020,7 +1044,7 @@ class TestJudgeDrivenWait:
mgr.set("retry after backoff")
with patch.object(
goals, "judge_goal",
- return_value=("wait", "rate limited", False, {"seconds": 120}),
+ return_value=("wait", "rate limited", False, {"seconds": 120}, False),
):
decision = mgr.evaluate_after_turn("Hit a 429, backing off.")
assert decision["verdict"] == "wait"
@@ -1050,7 +1074,7 @@ class TestJudgeDrivenWait:
mgr.set("do work")
with patch.object(
goals, "judge_goal",
- return_value=("continue", "more to do", False, None),
+ return_value=("continue", "more to do", False, None, False),
):
decision = mgr.evaluate_after_turn(
"made progress",
@@ -1118,7 +1142,7 @@ class TestSessionTriggerBarrier:
mgr.set("wait for the build to succeed")
with patch.object(
goals, "judge_goal",
- return_value=("wait", "blocked on build", False, {"session_id": "proc_t4"}),
+ return_value=("wait", "blocked on build", False, {"session_id": "proc_t4"}, False),
):
decision = mgr.evaluate_after_turn(
"Started the build watcher.",
@@ -1146,7 +1170,7 @@ class TestSessionTriggerBarrier:
# Loop resumes with a real judge verdict.
with patch.object(goals, "judge_goal",
- return_value=("continue", "build done", False, None)):
+ return_value=("continue", "build done", False, None, False)):
d3 = mgr.evaluate_after_turn("build succeeded")
assert d3["should_continue"] is True
@@ -1462,7 +1486,7 @@ class TestContractAndBackgroundCompose:
}]
with patch("agent.auxiliary_client.call_llm",
side_effect=self._capture_call_llm(captured)):
- verdict, reason, parse_failed, wait_directive = goals.judge_goal(
+ verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(
"ship the PR",
"I pushed and started the CI watcher; waiting on it now.",
contract=GoalContract(verification="PR CI goes green"),
@@ -1492,7 +1516,7 @@ class TestContractAndBackgroundCompose:
captured,
content='{"verdict": "done", "reason": "CI is green, evidence shown"}',
)):
- verdict, reason, parse_failed, wait_directive = goals.judge_goal(
+ verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(
"ship the PR",
"CI finished: 30 passed, 0 failed. Done.",
contract=GoalContract(verification="PR CI goes green"),
diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py
index fd9f25fdefb5..6c262180b9d8 100644
--- a/tests/hermes_cli/test_inventory.py
+++ b/tests/hermes_cli/test_inventory.py
@@ -276,6 +276,43 @@ def test_build_models_payload_can_probe_only_current_custom_provider():
assert mock_list.call_args.kwargs["probe_current_custom_provider"] is True
+def test_cli_model_picker_forwards_force_refresh_to_probe_flags():
+ """CLI /model picker must pass force_refresh to probe flags (#65652, #65650).
+
+ Normal open (/model bare) skips non-current probes; /model --refresh probes
+ all custom providers to freshen their model lists.
+ """
+ ctx = _empty_ctx()
+
+ # Normal open — skip non-current probes
+ force_refresh = False
+ with patch(
+ "hermes_cli.model_switch.list_authenticated_providers",
+ return_value=[],
+ ) as mock_list:
+ build_models_payload(
+ ctx,
+ probe_custom_providers=force_refresh,
+ probe_current_custom_provider=not force_refresh,
+ )
+ assert mock_list.call_args.kwargs["probe_custom_providers"] is False
+ assert mock_list.call_args.kwargs["probe_current_custom_provider"] is True
+
+ # Refresh open — probe everything
+ force_refresh = True
+ with patch(
+ "hermes_cli.model_switch.list_authenticated_providers",
+ return_value=[],
+ ) as mock_list:
+ build_models_payload(
+ ctx,
+ probe_custom_providers=force_refresh,
+ probe_current_custom_provider=not force_refresh,
+ )
+ assert mock_list.call_args.kwargs["probe_custom_providers"] is True
+ assert mock_list.call_args.kwargs["probe_current_custom_provider"] is False
+
+
def test_list_authenticated_providers_force_fresh_is_keyword_only():
"""``force_fresh_nous_tier`` must be keyword-only on the public listing API.
diff --git a/tests/hermes_cli/test_kanban_goal_mode.py b/tests/hermes_cli/test_kanban_goal_mode.py
index da0c2ae168f0..8f201341da69 100644
--- a/tests/hermes_cli/test_kanban_goal_mode.py
+++ b/tests/hermes_cli/test_kanban_goal_mode.py
@@ -181,8 +181,8 @@ def _patch_judge(monkeypatch, verdicts):
def _fake_judge(goal, response, subgoals=None, background_processes=None, **_kw):
v = seq.pop(0) if seq else "done"
- # 4-tuple contract: (verdict, reason, parse_failed, wait_directive)
- return v, f"scripted:{v}", False, None
+ # 5-tuple contract: verdict, reason, parse failure, wait, transport failure.
+ return v, f"scripted:{v}", False, None, False
monkeypatch.setattr(goals, "judge_goal", _fake_judge)
@@ -296,3 +296,88 @@ def test_loop_stops_if_task_reclaimed(monkeypatch):
first_response="x",
)
assert res["outcome"] == "stopped"
+
+
+# ---------------------------------------------------------------------------
+# CLI judge gate tests (hermes kanban complete bypass fix)
+# ---------------------------------------------------------------------------
+
+class TestCLIJudgeGate:
+ """hermes kanban complete must apply the same goal_mode judge gate as the
+ kanban_complete tool (Issue #38367 sibling gap).
+
+ Uses mocks for kb.get_task and kb.complete_task to avoid depending on the
+ full kanban_db schema; the gate logic is the unit under test.
+ """
+
+ def _run(self, monkeypatch, *, goal_mode=True, judge_available=True,
+ verdict="done", reason="", complete_ok=True, summary="done"):
+ import argparse
+ import types
+ from unittest.mock import MagicMock
+ from hermes_cli.kanban import _cmd_complete
+
+ fake_task = types.SimpleNamespace(
+ goal_mode=goal_mode,
+ title="Finish report",
+ body="acceptance: criteria",
+ )
+ fake_conn = MagicMock()
+ complete_calls: list = []
+
+ def fake_connect_closing():
+ from contextlib import contextmanager
+ @contextmanager
+ def _cm():
+ yield fake_conn
+ return _cm()
+
+ def fake_complete_task(conn, tid, **kw):
+ complete_calls.append(tid)
+ return complete_ok
+
+ monkeypatch.setattr("hermes_cli.kanban.kb.get_task", lambda conn, tid: fake_task)
+ monkeypatch.setattr("hermes_cli.kanban.kb.complete_task", fake_complete_task)
+ monkeypatch.setattr("hermes_cli.kanban.kb.connect_closing", fake_connect_closing)
+ monkeypatch.setattr("hermes_cli.kanban._worker_run_id_for", lambda _: None)
+
+ _aux_client = (object(), "judge-model") if judge_available else (None, None)
+ monkeypatch.setattr(
+ "agent.auxiliary_client.get_text_auxiliary_client",
+ lambda name: _aux_client,
+ )
+ # Match the real judge_goal contract:
+ # (verdict, reason, parse_failed, wait_directive, transport_failed)
+ monkeypatch.setattr(
+ "hermes_cli.goals.judge_goal",
+ lambda **kw: (verdict, reason, False, None, False),
+ )
+
+ args = argparse.Namespace(task_ids=["t1"], summary=summary, result=None, metadata=None)
+ return _cmd_complete(args), complete_calls
+
+ def test_judge_rejects_premature_completion(self, monkeypatch):
+ rc, complete_calls = self._run(
+ monkeypatch, verdict="continue", reason="criteria not met"
+ )
+ assert rc != 0, "judge rejection must produce non-zero exit code"
+ assert complete_calls == [], (
+ "complete_task must NOT be invoked when the judge rejects"
+ )
+
+ def test_judge_allows_accepted_completion(self, monkeypatch):
+ rc, complete_calls = self._run(monkeypatch, verdict="done")
+ assert rc == 0
+ assert complete_calls == ["t1"]
+
+ def test_judge_unavailable_fails_open(self, monkeypatch):
+ """No auxiliary client configured → gate skipped, task completes."""
+ rc, complete_calls = self._run(monkeypatch, judge_available=False)
+ assert rc == 0
+ assert complete_calls == ["t1"]
+
+ def test_non_goal_mode_task_skips_gate(self, monkeypatch):
+ """Plain (non-goal_mode) tasks are never sent to the judge."""
+ rc, complete_calls = self._run(monkeypatch, goal_mode=False)
+ assert rc == 0
+ assert complete_calls == ["t1"]
diff --git a/tests/hermes_cli/test_kimi_cn_provider_listing.py b/tests/hermes_cli/test_kimi_cn_provider_listing.py
new file mode 100644
index 000000000000..1a49b0d8fd1a
--- /dev/null
+++ b/tests/hermes_cli/test_kimi_cn_provider_listing.py
@@ -0,0 +1,176 @@
+"""Test that kimi-coding and kimi-coding-cn both appear in the /model picker.
+
+Both providers share the same models.dev ID (kimi-for-coding) but are distinct
+profiles with different API keys, base URLs, and endpoints. The /model picker
+must show both so users can pick the right endpoint for their key type.
+
+Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip
+kimi-coding-cn after kimi-coding was emitted because both map to
+``kimi-for-coding`` (#10526). The fix deduplicates by
+``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles
+through.
+"""
+
+import os
+from unittest.mock import patch
+
+from hermes_cli.model_switch import (
+ list_authenticated_providers,
+ parse_model_flags,
+ switch_model,
+)
+from hermes_cli.providers import resolve_provider_full
+
+
+# -- Only KIMI_CN_API_KEY set ------------------------------------------------
+
+
+@patch.dict(os.environ, {"KIMI_CN_API_KEY": "sk-cn-fake"}, clear=False)
+def test_kimi_cn_appears_when_only_cn_key_set():
+ """kimi-coding-cn should appear when only KIMI_CN_API_KEY is set."""
+ providers = list_authenticated_providers(current_provider="kimi-coding-cn")
+
+ # kimi-coding-cn must be listed (it has credentials)
+ cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
+ assert cn is not None, (
+ "kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
+ )
+ assert cn["is_current"] is True
+ assert cn["total_models"] > 0
+
+ # kimi-coding must NOT appear (no KIMI_API_KEY)
+ intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
+ assert intl is None, (
+ "kimi-coding should NOT appear when only KIMI_CN_API_KEY is set"
+ )
+
+
+# -- Only KIMI_API_KEY set ---------------------------------------------------
+
+
+@patch.dict(os.environ, {"KIMI_API_KEY": "sk-intl-fake"}, clear=False)
+def test_kimi_intl_appears_when_only_intl_key_set():
+ """kimi-coding (international) should appear when only KIMI_API_KEY is set."""
+ providers = list_authenticated_providers(current_provider="kimi-coding")
+
+ intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
+ assert intl is not None, (
+ "kimi-coding should appear when KIMI_API_KEY is set"
+ )
+ assert intl["is_current"] is True
+
+ # kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY)
+ cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
+ assert cn is None, (
+ "kimi-coding-cn should NOT appear when only KIMI_API_KEY is set"
+ )
+
+
+# -- Both keys set -----------------------------------------------------------
+
+@patch.dict(os.environ, {
+ "KIMI_API_KEY": "sk-intl-fake",
+ "KIMI_CN_API_KEY": "sk-cn-fake",
+}, clear=False)
+def test_both_kimi_providers_appear_when_both_keys_set():
+ """Both kimi-coding and kimi-coding-cn should appear when both keys set.
+
+ They are distinct profiles with different env vars and endpoints. The
+ existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn →
+ kimi-coding-cn) must NOT create additional rows.
+ """
+ providers = list_authenticated_providers(current_provider="kimi-coding")
+
+ # Both profile slugs must appear
+ intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
+ assert intl is not None, "kimi-coding should appear when KIMI_API_KEY is set"
+ assert intl["is_current"] is True
+
+ cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
+ assert cn is not None, (
+ "kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
+ )
+ assert cn["is_current"] is False # `current_provider` is kimi-coding
+
+ # Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot,
+ # moonshot-cn, kimi-cn)
+ kimi_slugs = [p["slug"] for p in providers if "kimi" in p["slug"] or "moonshot" in p["slug"]]
+ assert len(kimi_slugs) == 2, (
+ f"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), "
+ f"got {kimi_slugs}"
+ )
+
+
+# -- Both aliases deduped correctly ------------------------------------------
+
+@patch.dict(os.environ, {
+ "KIMI_API_KEY": "sk-intl-fake",
+ "KIMI_CN_API_KEY": "sk-cn-fake",
+}, clear=False)
+def test_kimi_aliases_not_listed_separately():
+ """Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows.
+
+ They resolve to the same canonical profile (kimi-coding) and should be
+ deduped. Only the canonical slug (kimi-coding) should appear.
+ """
+ providers = list_authenticated_providers(current_provider="kimi-coding-cn")
+
+ slugs = {p["slug"] for p in providers}
+ # These alias slugs must NOT appear
+ for bad_slug in ("kimi", "moonshot", "moonshot-cn", "kimi-cn"):
+ assert bad_slug not in slugs, (
+ f"Alias slug '{bad_slug}' must not appear in picker (resolved to "
+ f"canonical profile)"
+ )
+
+
+@patch.dict(os.environ, {
+ "KIMI_API_KEY": "sk-intl-fake",
+ "KIMI_CN_API_KEY": "sk-cn-fake",
+}, clear=False)
+def test_resolve_provider_full_preserves_kimi_cn_provider_identity():
+ """Explicit kimi-coding-cn must not collapse to shared models.dev alias.
+
+ Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(),
+ which mapped both kimi-coding and kimi-coding-cn to the models.dev alias
+ 'kimi-for-coding'. That silently rewired CN users to the international
+ endpoint and KIMI_API_KEY.
+ """
+ pdef = resolve_provider_full("kimi-coding-cn", None, None)
+ assert pdef is not None
+ assert pdef.id == "kimi-coding-cn"
+ assert pdef.base_url == "https://api.moonshot.cn/v1"
+ assert pdef.api_key_env_vars == ("KIMI_CN_API_KEY",)
+
+
+@patch.dict(os.environ, {
+ "KIMI_API_KEY": "sk-intl-fake",
+ "KIMI_CN_API_KEY": "sk-cn-fake",
+}, clear=False)
+def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint():
+ """/model ... --provider kimi-coding-cn must stay on moonshot.cn.
+
+ This hits the real switch path used by gateway /model: parse flags first,
+ then call switch_model() with explicit_provider. The result must not rewrite
+ the target provider/base_url back to the international Kimi endpoint.
+ """
+ model_input, explicit_provider, *_ = parse_model_flags(
+ "kimi-k2.6 —provider kimi-coding-cn"
+ )
+ result = switch_model(
+ raw_input=model_input,
+ current_provider="deepseek",
+ current_model="deepseek-v4-flash",
+ current_base_url="https://api.deepseek.com/v1",
+ current_api_key="***",
+ is_global=False,
+ explicit_provider=explicit_provider,
+ user_providers={},
+ custom_providers=None,
+ )
+
+ assert result.success is True
+ assert result.target_provider == "kimi-coding-cn"
+ assert result.new_model == "kimi-k2.6"
+ assert result.base_url == "https://api.moonshot.cn/v1"
+ assert result.api_key == "sk-cn-fake"
diff --git a/tests/hermes_cli/test_list_picker_providers.py b/tests/hermes_cli/test_list_picker_providers.py
index 480d42aa1605..56d759ce4b7a 100644
--- a/tests/hermes_cli/test_list_picker_providers.py
+++ b/tests/hermes_cli/test_list_picker_providers.py
@@ -19,6 +19,12 @@ import pytest
from hermes_cli import model_switch
+@pytest.fixture(autouse=True)
+def _disable_live_custom_provider_model_probe(monkeypatch):
+ """Keep custom-provider picker fixtures independent of local model servers."""
+ monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *_a, **_kw: None)
+
+
def _make_provider(slug, name=None, models=None, *, is_current=False,
is_user_defined=False, source="built-in", api_url=None):
"""Build a dict shaped like ``list_authenticated_providers`` output."""
@@ -293,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch):
assert row["slug"] == "custom:ollama"
assert row["is_current"] is True
assert row["models"] == ["glm-5.1", "qwen3"]
+
+
+# ---------------------------------------------------------------------------
+# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439)
+# ---------------------------------------------------------------------------
+#
+# A single Kimi credential used to surface TWO picker rows: the alias slug
+# "kimi" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical
+# "kimi-coding" (re-emitted by the CANONICAL_PROVIDERS cross-check pass),
+# both backed by the same kimi-for-coding models.dev provider. The picker
+# must list each authenticated credential exactly once, under the CANONICAL
+# slug ("kimi-coding") — matching list_authenticated_providers' other alias
+# rows and the overlay slug-resolution contract (see
+# test_overlay_slug_resolution.py).
+
+
+def _stub_kimi_discovery(monkeypatch, *, canonical):
+ """Isolate list_authenticated_providers to the Kimi alias family.
+
+ Restricts the models.dev map / catalog / overlays / canonical list to
+ just the Kimi entries and stubs the model-id fetch so discovery stays
+ offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list
+ the 2b cross-check pass should iterate.
+ """
+ import agent.models_dev as md
+ import hermes_cli.models as hm
+
+ kimi_map = {
+ "kimi": "kimi-for-coding",
+ "kimi-coding": "kimi-for-coding",
+ "moonshot": "kimi-for-coding",
+ "kimi-coding-cn": "kimi-for-coding",
+ }
+ monkeypatch.setattr(md, "PROVIDER_TO_MODELS_DEV", kimi_map)
+ monkeypatch.setattr(
+ md, "fetch_models_dev",
+ lambda *a, **k: {
+ "kimi-for-coding": {"name": "Kimi For Coding", "env": ["KIMI_API_KEY"]},
+ },
+ )
+
+ class _PInfo:
+ name = "Kimi For Coding"
+
+ monkeypatch.setattr(md, "get_provider_info", lambda _pid: _PInfo())
+ monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
+ monkeypatch.setattr(hm, "CANONICAL_PROVIDERS", canonical)
+ monkeypatch.setattr(hm, "cached_provider_model_ids",
+ lambda *a, **k: ["kimi-k2.6", "kimi-k2.5"])
+ monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None)
+
+
+def test_single_kimi_credential_yields_one_canonical_row(monkeypatch):
+ """One Kimi key yields a single row under the canonical 'kimi-coding' slug."""
+ import hermes_cli.models as hm
+
+ _stub_kimi_discovery(
+ monkeypatch,
+ canonical=[hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc")],
+ )
+ monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
+
+ rows = model_switch.list_authenticated_providers(max_models=10)
+ slugs = [r["slug"] for r in rows]
+
+ # Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug —
+ # not both the alias ("kimi") and its canonical ("kimi-coding").
+ kimi_rows = [s for s in slugs if s in {"kimi", "kimi-coding"}]
+ assert kimi_rows == ["kimi-coding"], (
+ f"expected a single canonical Kimi row, got: {slugs}"
+ )
+ assert slugs.count("kimi-coding") == 1
+ assert "kimi" not in slugs
+
+
+def test_distinct_kimi_china_credential_still_listed(monkeypatch):
+ """A separate China (kimi-coding-cn) credential remains its own row.
+
+ Negative-control guard: the de-dup must collapse only the alias/canonical
+ pair that share a credential, not legitimately distinct providers.
+ """
+ import hermes_cli.models as hm
+
+ _stub_kimi_discovery(
+ monkeypatch,
+ canonical=[
+ hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc"),
+ hm.ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "desc"),
+ ],
+ )
+ monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
+ monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test-kimi-cn")
+
+ rows = model_switch.list_authenticated_providers(max_models=10)
+ slugs = [r["slug"] for r in rows]
+
+ assert "kimi-coding" in slugs # canonical global row
+ assert slugs.count("kimi-coding") == 1
+ assert "kimi" not in slugs # alias collapsed into the canonical row
+ assert "kimi-coding-cn" in slugs # distinct China endpoint preserved
diff --git a/tests/hermes_cli/test_model_picker_excluded_providers.py b/tests/hermes_cli/test_model_picker_excluded_providers.py
new file mode 100644
index 000000000000..f7c781857cd6
--- /dev/null
+++ b/tests/hermes_cli/test_model_picker_excluded_providers.py
@@ -0,0 +1,128 @@
+"""Tests that ``model_catalog.excluded_providers`` hides providers from the
+interactive ``hermes model`` CLI picker.
+
+The CLI picker (``hermes_cli.main.select_provider_and_model``) builds its
+provider menu from ``CANONICAL_PROVIDERS`` via ``group_providers`` — a
+separate code path from ``list_authenticated_providers``. These tests
+verify the exclusion config is honored there too, matching the
+gateway/TUI picker behavior.
+"""
+
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture
+def config_home(tmp_path, monkeypatch):
+ """Isolated HERMES_HOME with a minimal config."""
+ home = tmp_path / "hermes"
+ home.mkdir()
+ config_yaml = home / "config.yaml"
+ config_yaml.write_text("model: old-model\ncustom_providers: []\n")
+ env_file = home / ".env"
+ env_file.write_text("")
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.delenv("HERMES_MODEL", raising=False)
+ monkeypatch.delenv("LLM_MODEL", raising=False)
+ monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ return home
+
+
+def _write_config(home, **top_level):
+ import yaml
+ cfg = {"model": "old-model", "custom_providers": []}
+ cfg.update(top_level)
+ (home / "config.yaml").write_text(yaml.safe_dump(cfg))
+
+
+def _capture_provider_labels(config_home):
+ """Drive ``select_provider_and_model`` and return the provider-menu labels
+ shown to the user (the first ``_prompt_provider_choice`` call). Cancels
+ immediately after capturing."""
+ from hermes_cli.main import select_provider_and_model
+
+ captured: dict = {}
+
+ def _capture_and_cancel(labels, default=0, title=None):
+ # Only capture the top-level provider menu (the first call).
+ if "labels" not in captured:
+ captured["labels"] = list(labels)
+ return None # cancel
+
+ with patch("hermes_cli.main._prompt_provider_choice",
+ side_effect=_capture_and_cancel), \
+ patch("builtins.print"):
+ select_provider_and_model()
+
+ return captured.get("labels", [])
+
+
+def test_cli_picker_hides_excluded_provider(config_home):
+ """``excluded_providers: [openrouter]`` must remove the OpenRouter row
+ from the ``hermes model`` provider menu."""
+ _write_config(config_home, **{"model_catalog": {"excluded_providers": ["openrouter"]}})
+
+ labels = _capture_provider_labels(config_home)
+ assert labels, "provider menu was empty"
+ assert not any("OpenRouter" in lbl for lbl in labels), (
+ f"OpenRouter should be hidden by excluded_providers, got: {labels}"
+ )
+
+
+def test_cli_picker_hides_excluded_provider_by_alias(config_home):
+ """Exclusion by an alias (not the canonical slug) must also hide the
+ provider, matching ``list_authenticated_providers``' matching against
+ hermes_id / alias names."""
+ # 'openai' is an alias-style hermes id; ensure excluding it hides the
+ # canonical openai provider row if present. Use the canonical slug's
+ # alias from _PROVIDER_ALIASES to stay robust to renames.
+ from hermes_cli.models import _PROVIDER_ALIASES, CANONICAL_PROVIDERS
+
+ # Find a canonical provider that has at least one alias and is a leaf
+ # row (not folded into a multi-member group) so its label appears
+ # directly. Pick the first such provider.
+ target_slug = None
+ target_alias = None
+ for alias, canon in _PROVIDER_ALIASES.items():
+ if canon and any(p.slug == canon for p in CANONICAL_PROVIDERS):
+ target_slug = canon
+ target_alias = alias
+ break
+ if target_slug is None:
+ pytest.skip("no aliased canonical provider available to test")
+
+ from hermes_cli.models import _PROVIDER_LABELS
+ target_label_fragment = _PROVIDER_LABELS.get(target_slug, target_slug)
+
+ # Baseline: the provider appears without exclusion.
+ _write_config(config_home)
+ baseline = _capture_provider_labels(config_home)
+ assert any(target_label_fragment in lbl for lbl in baseline), (
+ f"sanity: {target_slug} ({target_label_fragment!r}) should appear by "
+ f"default; labels={baseline}"
+ )
+
+ # Excluding by alias hides it.
+ _write_config(
+ config_home,
+ **{"model_catalog": {"excluded_providers": [target_alias]}},
+ )
+ excluded_labels = _capture_provider_labels(config_home)
+ assert not any(target_label_fragment in lbl for lbl in excluded_labels), (
+ f"excluding alias {target_alias!r} should hide {target_slug}; "
+ f"labels={excluded_labels}"
+ )
+
+
+def test_cli_picker_empty_excluded_is_noop(config_home):
+ """An empty ``excluded_providers`` list must not change the menu."""
+ _write_config(config_home, **{"model_catalog": {"excluded_providers": []}})
+ excluded_labels = _capture_provider_labels(config_home)
+
+ _write_config(config_home)
+ baseline_labels = _capture_provider_labels(config_home)
+
+ assert excluded_labels == baseline_labels
diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py
index 06722b4f9f1d..2f9784ee4401 100644
--- a/tests/hermes_cli/test_model_switch_custom_providers.py
+++ b/tests/hermes_cli/test_model_switch_custom_providers.py
@@ -6,6 +6,7 @@ only looked at `providers:`.
"""
import hermes_cli.providers as providers_mod
+import pytest
from hermes_cli.model_switch import list_authenticated_providers, switch_model
from hermes_cli.providers import resolve_provider_full
@@ -18,6 +19,12 @@ _MOCK_VALIDATION = {
}
+@pytest.fixture(autouse=True)
+def _disable_live_custom_provider_model_probe(monkeypatch):
+ """Keep custom-provider picker fixtures independent of local model servers."""
+ monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *_a, **_kw: None)
+
+
def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
"""No-args /model menus should include saved custom_providers entries."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
@@ -664,6 +671,7 @@ def test_list_authenticated_providers_groups_same_endpoint(monkeypatch):
"api_key": "ollama", "model": "qwen3-coder"},
],
max_models=50,
+ probe_custom_providers=False,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
@@ -748,6 +756,7 @@ def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypat
"api_key": "ollama", "model": "qwen3-coder"},
],
max_models=50,
+ probe_custom_providers=False,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
@@ -841,6 +850,7 @@ def test_list_authenticated_providers_total_models_reflects_grouped_count(monkey
user_providers={},
custom_providers=entries,
max_models=4,
+ probe_custom_providers=False,
)
groups = [p for p in providers if p.get("is_user_defined")]
@@ -1326,3 +1336,358 @@ def test_resolve_custom_provider_bare_custom_self_heal_passes_key_env():
assert resolved is not None
assert resolved.api_key_env_vars == ("XIAOMI_MIMO_API_KEY",)
+
+
+def test_discovered_models_auto_saved_to_cache(monkeypatch):
+ """Discovered models are persisted to config so ``discover_models: false``
+ has a populated cache on the next read (#65652).
+
+ When a successful probe returns live models, ``_save_discovered_models_to_config``
+ must be called with the provider's base_url and the discovered model list.
+ """
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
+
+ save_calls = []
+
+ def fake_fetch_api_models(api_key, base_url, **kwargs):
+ return ["discovered-a", "discovered-b", "discovered-c"]
+
+ monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
+ monkeypatch.setattr(
+ "hermes_cli.model_switch._save_discovered_models_to_config",
+ lambda api_url, model_ids: save_calls.append((api_url, model_ids)),
+ )
+
+ custom_providers = [
+ {
+ "name": "my-gateway",
+ "api_key": "***",
+ "base_url": "https://gateway.example.com/v1",
+ "discover_models": True,
+ "model": "only-model",
+ "models": {"only-model": {"context_length": 128000}},
+ }
+ ]
+
+ providers = list_authenticated_providers(
+ current_provider="my-gateway",
+ current_base_url="https://gateway.example.com/v1",
+ custom_providers=custom_providers,
+ max_models=50,
+ probe_custom_providers=True,
+ )
+
+ assert len(save_calls) == 1, (
+ "_save_discovered_models_to_config must be called after a successful probe"
+ )
+ assert save_calls[0][0] == "https://gateway.example.com/v1"
+ assert save_calls[0][1] == ["discovered-a", "discovered-b", "discovered-c"]
+
+ gateway_prov = next(
+ (p for p in providers if p.get("api_url") == "https://gateway.example.com/v1"),
+ None,
+ )
+ assert gateway_prov is not None
+ assert gateway_prov["models"] == ["discovered-a", "discovered-b", "discovered-c"]
+
+
+def test_discovered_models_not_saved_on_empty_probe(monkeypatch):
+ """When a probe returns an empty list, no auto-save must happen (#65652)."""
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
+
+ save_calls = []
+
+ def fake_fetch_api_models(api_key, base_url, **kwargs):
+ return []
+
+ monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
+ monkeypatch.setattr(
+ "hermes_cli.model_switch._save_discovered_models_to_config",
+ lambda api_url, model_ids: save_calls.append((api_url, model_ids)),
+ )
+
+ custom_providers = [
+ {
+ "name": "my-gateway",
+ "api_key": "***",
+ "base_url": "https://gateway.example.com/v1",
+ "discover_models": True,
+ "model": "only-model",
+ }
+ ]
+
+ list_authenticated_providers(
+ current_provider="my-gateway",
+ current_base_url="https://gateway.example.com/v1",
+ custom_providers=custom_providers,
+ max_models=50,
+ probe_custom_providers=True,
+ )
+
+ assert save_calls == [], "Empty probe must not trigger a save"
+
+
+def test_save_discovered_models_skips_unchanged(monkeypatch):
+ """``_save_discovered_models_to_config`` must not write config when the
+ model list hasn't changed (#65652)."""
+ from hermes_cli.model_switch import _save_discovered_models_to_config
+
+ save_calls = []
+
+ def fake_save(config):
+ save_calls.append(dict(config))
+
+ monkeypatch.setattr("hermes_cli.config.save_config", fake_save)
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {
+ "custom_providers": [
+ {
+ "name": "my-gateway",
+ "base_url": "https://gateway.example.com/v1",
+ "models": ["model-a", "model-b"],
+ }
+ ]
+ },
+ )
+
+ # Same list — no write
+ _save_discovered_models_to_config(
+ "https://gateway.example.com/v1",
+ ["model-a", "model-b"],
+ )
+ assert save_calls == [], "Unchanged models must not trigger config write"
+
+ # Changed list — write
+ _save_discovered_models_to_config(
+ "https://gateway.example.com/v1",
+ ["model-a", "model-b", "model-c"],
+ )
+ assert len(save_calls) == 1, "Changed models must trigger config write"
+ updated = save_calls[0]["custom_providers"][0]
+ assert updated["models"] == ["model-a", "model-b", "model-c"]
+
+
+def test_save_discovered_models_noop_on_empty_args(monkeypatch):
+ """``_save_discovered_models_to_config`` is a no-op when api_url or
+ model_ids are blank (#65652)."""
+ from hermes_cli.model_switch import _save_discovered_models_to_config
+
+ load_calls = 0
+
+ def fake_load():
+ nonlocal load_calls
+ load_calls += 1
+ return {"custom_providers": []}
+
+ monkeypatch.setattr("hermes_cli.config.load_config", fake_load)
+
+ _save_discovered_models_to_config("", ["a"])
+ _save_discovered_models_to_config("https://x.com", [])
+ _save_discovered_models_to_config("", [])
+
+ assert load_calls == 0, "load_config must not be called for empty args"
+
+
+def test_save_discovered_models_preserves_dict_form(monkeypatch):
+ """``_save_discovered_models_to_config`` must not replace a dict-form
+ ``models`` mapping (per-model metadata like ``context_length``) with
+ a flat list of strings (#67841)."""
+ from hermes_cli.model_switch import _save_discovered_models_to_config
+
+ save_calls = []
+
+ def fake_save(config):
+ save_calls.append(dict(config))
+
+ monkeypatch.setattr("hermes_cli.config.save_config", fake_save)
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {
+ "custom_providers": [
+ {
+ "name": "my-gateway",
+ "base_url": "https://gateway.example.com/v1",
+ "models": {
+ "configured-model": {"context_length": 8192},
+ },
+ }
+ ]
+ },
+ )
+
+ # Dict-form models must NOT be overwritten by discovered models
+ _save_discovered_models_to_config(
+ "https://gateway.example.com/v1",
+ ["configured-model", "discovered-model"],
+ )
+ assert save_calls == [], (
+ "Dict-form models must not be replaced with a flat list"
+ )
+
+
+def test_save_discovered_models_preserves_list_of_dicts_form(monkeypatch):
+ """``_save_discovered_models_to_config`` must not replace a list-of-dicts
+ ``models`` form (per-model metadata via ``[{id: ...}]``) with a flat list
+ of strings (#67841 sibling site)."""
+ from hermes_cli.model_switch import _save_discovered_models_to_config
+
+ save_calls = []
+
+ def fake_save(config):
+ save_calls.append(dict(config))
+
+ monkeypatch.setattr("hermes_cli.config.save_config", fake_save)
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {
+ "custom_providers": [
+ {
+ "name": "my-gateway",
+ "base_url": "https://gateway.example.com/v1",
+ "models": [
+ {"id": "configured-model", "context_length": 8192},
+ {"id": "other-model", "context_length": 4096},
+ ],
+ }
+ ]
+ },
+ )
+
+ # List-of-dicts models must NOT be overwritten by discovered models
+ _save_discovered_models_to_config(
+ "https://gateway.example.com/v1",
+ ["configured-model", "discovered-model"],
+ )
+ assert save_calls == [], (
+ "List-of-dicts models must not be replaced with a flat list"
+ )
+
+
+def test_shared_url_different_display_names_are_separate_rows(monkeypatch):
+ """Multiple custom_providers entries sharing base_url + api_key + api_mode
+ but with *different* display-name prefixes (e.g. a proxy fronting
+ cerebras, groq and perplexity at one URL) must each get their own picker
+ row, not collapse into one."""
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
+ # Stub live discovery so the test is deterministic regardless of network.
+ monkeypatch.setattr(
+ "hermes_cli.models.fetch_api_models",
+ lambda api_key, base_url, **kwargs: [],
+ )
+
+ providers = list_authenticated_providers(
+ current_provider="openrouter",
+ current_base_url="https://openrouter.ai/api/v1",
+ user_providers={},
+ custom_providers=[
+ {"name": "Cerebras", "base_url": "https://proxy.example.com/v1",
+ "api_key": "proxy-key", "model": "llama-4-scout"},
+ {"name": "Groq", "base_url": "https://proxy.example.com/v1",
+ "api_key": "proxy-key", "model": "llama-4-scout"},
+ {"name": "Perplexity", "base_url": "https://proxy.example.com/v1",
+ "api_key": "proxy-key", "model": "sonar-pro"},
+ ],
+ max_models=50,
+ )
+
+ custom = [p for p in providers if p.get("is_user_defined")]
+ names = sorted(p["name"] for p in custom)
+ assert names == ["Cerebras", "Groq", "Perplexity"], (
+ f"expected three separate rows, got {names}"
+ )
+ # Each row carries only its own model (no cross-contamination).
+ by_name = {p["name"]: p["models"] for p in custom}
+ assert by_name["Cerebras"] == ["llama-4-scout"]
+ assert by_name["Groq"] == ["llama-4-scout"]
+ assert by_name["Perplexity"] == ["sonar-pro"]
+
+
+def test_shared_url_per_model_suffix_still_collapses(monkeypatch):
+ """Per-model suffix entries sharing the same display-name prefix (e.g.
+ "Ollama — A", "Ollama — B") must still collapse into one row even with
+ the display-prefix grouping dimension."""
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
+ # Stub live discovery so a locally-running Ollama cannot override the
+ # static configured models and make the assertion flaky.
+ monkeypatch.setattr(
+ "hermes_cli.models.fetch_api_models",
+ lambda api_key, base_url, **kwargs: [],
+ )
+
+ providers = list_authenticated_providers(
+ current_provider="openrouter",
+ current_base_url="https://openrouter.ai/api/v1",
+ user_providers={},
+ custom_providers=[
+ {"name": "Ollama \u2014 GLM 5.1", "base_url": "http://localhost:11434/v1",
+ "api_key": "ollama", "model": "glm-5.1"},
+ {"name": "Ollama \u2014 Qwen3-coder", "base_url": "http://localhost:11434/v1",
+ "api_key": "ollama", "model": "qwen3-coder"},
+ ],
+ max_models=50,
+ )
+
+ custom = [p for p in providers if p.get("is_user_defined")]
+ assert len(custom) == 1, (
+ f"expected one collapsed row, got {[p['name'] for p in custom]}"
+ )
+ assert custom[0]["name"] == "Ollama"
+ assert set(custom[0]["models"]) == {"glm-5.1", "qwen3-coder"}
+
+
+def test_excluded_providers_hides_builtin_row(monkeypatch):
+ """``excluded_providers`` must hide a built-in provider row that would
+ otherwise surface when its credentials are present."""
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
+
+ baseline = list_authenticated_providers(
+ current_provider="openrouter",
+ current_base_url="https://openrouter.ai/api/v1",
+ user_providers={},
+ custom_providers=[],
+ max_models=50,
+ )
+ assert any(p["slug"] == "openrouter" for p in baseline), (
+ "sanity: openrouter row must appear when OPENROUTER_API_KEY is set"
+ )
+
+ filtered = list_authenticated_providers(
+ current_provider="openrouter",
+ current_base_url="https://openrouter.ai/api/v1",
+ user_providers={},
+ custom_providers=[],
+ max_models=50,
+ excluded_providers=["openrouter"],
+ )
+ assert not any(p["slug"] == "openrouter" for p in filtered), (
+ "excluded_providers=['openrouter'] must hide the openrouter row"
+ )
+
+
+def test_excluded_providers_empty_is_noop(monkeypatch):
+ """An empty ``excluded_providers`` list must not change picker output."""
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
+ monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
+
+ a = list_authenticated_providers(
+ current_provider="openrouter",
+ user_providers={},
+ custom_providers=[],
+ max_models=50,
+ )
+ b = list_authenticated_providers(
+ current_provider="openrouter",
+ user_providers={},
+ custom_providers=[],
+ max_models=50,
+ excluded_providers=[],
+ )
+ assert [p["slug"] for p in a] == [p["slug"] for p in b]
diff --git a/tests/hermes_cli/test_model_switch_persist_default.py b/tests/hermes_cli/test_model_switch_persist_default.py
index 912bd7afe47e..02e4521ffb8e 100644
--- a/tests/hermes_cli/test_model_switch_persist_default.py
+++ b/tests/hermes_cli/test_model_switch_persist_default.py
@@ -1,11 +1,12 @@
-"""Tests for persist-by-default model switching.
+"""Tests for session-scoped-by-default model switching.
Covers:
- ``parse_model_flags`` recognises ``--session`` (and keeps ``--global``).
- ``resolve_persist_behavior`` applies the config-gated default and the
``--session`` / ``--global`` overrides.
-- The default (no flags) persists, which is the user-facing fix: a plain
- ``/model `` survives across sessions.
+- The default (no flags) is session-only, which is the user-facing fix: a
+ plain ``/model `` affects only the current session unless the user
+ passes ``--global`` or sets ``model.persist_switch_by_default: true``.
"""
from unittest.mock import patch
@@ -73,10 +74,10 @@ class TestResolvePersistBehavior:
with _config({"model": {"persist_switch_by_default": False}}):
assert resolve_persist_behavior(True, False) is True
- def test_default_persists_when_config_missing(self):
- # No model section at all → built-in default (True).
+ def test_default_session_only_when_config_missing(self):
+ # No model section at all → built-in default (False, session-only).
with _config({}):
- assert resolve_persist_behavior(False, False) is True
+ assert resolve_persist_behavior(False, False) is False
def test_default_persists_when_key_true(self):
with _config({"model": {"persist_switch_by_default": True}}):
@@ -87,15 +88,35 @@ class TestResolvePersistBehavior:
assert resolve_persist_behavior(False, False) is False
def test_default_when_model_is_flat_string(self):
- # Fresh install: ``model: ""`` (not a dict) → built-in default True.
+ # Fresh install: ``model: ""`` (not a dict) → built-in default False.
with _config({"model": ""}):
- assert resolve_persist_behavior(False, False) is True
+ assert resolve_persist_behavior(False, False) is False
def test_session_overrides_global_when_both_set(self):
# --session is the explicit opt-out and wins over --global.
with _config({"model": {"persist_switch_by_default": True}}):
assert resolve_persist_behavior(True, True) is False
+ def test_provider_flag_defaults_to_session_only(self):
+ # --provider without --global/--session → session only.
+ with _config({"model": {"persist_switch_by_default": True}}):
+ assert resolve_persist_behavior(False, False, explicit_provider="anthropic") is False
+
+ def test_provider_with_global_still_persists(self):
+ # --provider + --global → persists.
+ with _config({"model": {"persist_switch_by_default": False}}):
+ assert resolve_persist_behavior(True, False, explicit_provider="anthropic") is True
+
+ def test_provider_with_session_still_session_only(self):
+ # --provider + --session → session only.
+ with _config({"model": {"persist_switch_by_default": True}}):
+ assert resolve_persist_behavior(False, True, explicit_provider="anthropic") is False
+
+ def test_no_provider_uses_config_default(self):
+ # No --provider → respects config default (True).
+ with _config({"model": {"persist_switch_by_default": True}}):
+ assert resolve_persist_behavior(False, False, explicit_provider="") is True
+
# ---------------------------------------------------------------------------
# helper
diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py
index ca5fb8219ea9..89a3d9fed704 100644
--- a/tests/hermes_cli/test_models.py
+++ b/tests/hermes_cli/test_models.py
@@ -976,3 +976,20 @@ class TestCodexSoftAcceptPlausibilityGate:
r = validate_requested_model("gpt-5.5", "openai-codex")
assert r["accepted"] is True
assert r["recognized"] is True
+
+
+class TestClaudeSonnet5InCuratedLists:
+ """Regression: Claude Sonnet 5 must appear in curated model lists (#55846)."""
+
+ def test_anthropic_native_list_includes_sonnet_5(self):
+ from hermes_cli.models import _PROVIDER_MODELS
+ assert "claude-sonnet-5" in _PROVIDER_MODELS["anthropic"]
+
+ def test_openrouter_fallback_includes_sonnet_5(self):
+ from hermes_cli.models import OPENROUTER_MODELS
+ ids = [mid for mid, _ in OPENROUTER_MODELS]
+ assert "anthropic/claude-sonnet-5" in ids
+
+ def test_nous_list_includes_sonnet_5(self):
+ from hermes_cli.models import _PROVIDER_MODELS
+ assert "anthropic/claude-sonnet-5" in _PROVIDER_MODELS["nous"]
diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py
index 78b69e0d0ecc..168dd934f96a 100644
--- a/tests/hermes_cli/test_models_dev_preferred_merge.py
+++ b/tests/hermes_cli/test_models_dev_preferred_merge.py
@@ -97,14 +97,15 @@ class TestProviderModelIdsPreferred:
assert "claude-opus-4-7" in out
assert "kimi-k2.6" in out
- def test_kimi_coding_offline_catalog_includes_k2_7_code(self):
- """Native Kimi users must see the newest Code model without live catalog help."""
+ def test_kimi_coding_offline_catalog_includes_k3(self):
+ """Native Kimi users must see the newest models without live catalog help."""
assert "kimi-coding" not in _MODELS_DEV_PREFERRED
with patch("agent.models_dev.list_agentic_models", return_value=[]):
out = provider_model_ids("kimi-coding")
+ assert "kimi-k3" in out
assert "kimi-k2.7-code" in out
- def test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code(self):
+ def test_kimi_coding_live_catalog_does_not_hide_curated_k3(self):
"""Kimi /models can lag inference; live results must not replace curated."""
with (
patch(
@@ -114,8 +115,8 @@ class TestProviderModelIdsPreferred:
patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]),
):
out = provider_model_ids("kimi-coding")
- # Curated-first order; curated newest (k2.7-code) stays ahead of live.
- assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"]
+ # Curated-first order; curated newest (k3) stays ahead of live.
+ assert out[:3] == ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6"]
def test_k3_live_discovery_is_scoped_to_kimi_coding_endpoint(self):
"""Coding keys discover K3; legacy Moonshot keys must not advertise it."""
@@ -171,7 +172,7 @@ class TestProviderModelIdsPreferred:
custom_models = provider_model_ids("kimi-coding")
assert "k3" in coding_models
- assert coding_models[0] == "kimi-k2.7-code"
+ assert coding_models[0] == "kimi-k3"
assert all(model.lower() != "k3" for model in legacy_models)
assert all(model.lower() != "k3" for model in custom_models)
@@ -194,7 +195,7 @@ class TestProviderModelIdsPreferred:
_model_flow_kimi({}, current_model="")
assert captured["models"] == _PROVIDER_MODELS["kimi-coding"]
- assert captured["models"][0] == "kimi-k2.7-code"
+ assert captured["models"][0] == "kimi-k3"
class TestOpenRouterAndNousUnchanged:
diff --git a/tests/hermes_cli/test_placeholder_usage.py b/tests/hermes_cli/test_placeholder_usage.py
index 3479d8f5703b..b21834927cb9 100644
--- a/tests/hermes_cli/test_placeholder_usage.py
+++ b/tests/hermes_cli/test_placeholder_usage.py
@@ -18,7 +18,15 @@ def test_config_set_usage_marks_placeholders(capsys):
assert exc.value.code == 1
out = capsys.readouterr().out
- assert "Usage: hermes config set " in out
+ # The usage line documents the optional --force flag added in #34067
+ # (schema validation for unknown keys). Placeholder convention is preserved:
+ # the literal ```` and ```` markers must still be present so
+ # downstream tooling can detect placeholder syntax.
+ assert "Usage: hermes config set" in out
+ assert "" in out
+ assert "" in out
+ # --force escape hatch must be documented in the usage line.
+ assert "--force" in out
def test_config_unknown_command_help_marks_placeholders(capsys):
diff --git a/tests/hermes_cli/test_provider_section3_grouping.py b/tests/hermes_cli/test_provider_section3_grouping.py
new file mode 100644
index 000000000000..6f8c73934d7a
--- /dev/null
+++ b/tests/hermes_cli/test_provider_section3_grouping.py
@@ -0,0 +1,149 @@
+"""Regression tests for section-3 (``providers:``) same-endpoint grouping in
+``list_authenticated_providers`` and for ``format_model_for_display``.
+
+Salvaged with PR #36998 (@antydizajn): section 3 folds ``providers:`` entries
+that share (api_url, credential, api_mode, extra_headers) into one picker row,
+mirroring section 4's grouping for ``custom_providers:``. These are invariant
+tests — grouping identity, header-routed separation, list-of-dict model
+declarations, and display-only RID stripping.
+"""
+
+import hermes_cli.providers as providers_mod
+from hermes_cli.model_switch import (
+ format_model_for_display,
+ list_authenticated_providers,
+)
+
+
+def _providers(monkeypatch, user_providers):
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
+ monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])
+ return list_authenticated_providers(
+ user_providers=user_providers,
+ custom_providers=[],
+ max_models=50,
+ )
+
+
+def _user_rows(rows):
+ return [p for p in rows if p.get("source") == "user-config"]
+
+
+def test_same_endpoint_same_credential_entries_fold_to_one_row(monkeypatch):
+ """Two providers: entries differing only by model id collapse into one
+ picker row carrying both models (the Palantir Foundry case)."""
+ rows = _user_rows(_providers(monkeypatch, {
+ "palantir-claude46": {
+ "name": "Palantir Claude 4.6 Opus",
+ "base_url": "https://foundry.example.com/anthropic",
+ "key_env": "PALANTIR_TOKEN",
+ "api_mode": "anthropic_messages",
+ "model": "ri.language-model-service..language-model.anthropic-claude-4-6-opus",
+ },
+ "palantir-claude47": {
+ "name": "Palantir Claude 4.7 Opus",
+ "base_url": "https://foundry.example.com/anthropic",
+ "key_env": "PALANTIR_TOKEN",
+ "api_mode": "anthropic_messages",
+ "model": "ri.language-model-service..language-model.anthropic-claude-4-7-opus",
+ },
+ }))
+ assert len(rows) == 1
+ row = rows[0]
+ assert row["slug"] == "palantir-claude46" # first member's slug wins
+ assert row["name"] == "Palantir Claude" # version suffix stripped
+ assert len(row["models"]) == 2
+
+
+def test_different_api_mode_keeps_distinct_rows(monkeypatch):
+ """Same host + credential but a different wire protocol must not fold."""
+ rows = _user_rows(_providers(monkeypatch, {
+ "proxy-claude": {
+ "name": "Proxy Claude",
+ "base_url": "https://proxy.example.com/v1",
+ "key_env": "PROXY_TOKEN",
+ "api_mode": "anthropic_messages",
+ "model": "claude-opus-4.6",
+ },
+ "proxy-gpt": {
+ "name": "Proxy GPT",
+ "base_url": "https://proxy.example.com/v1",
+ "key_env": "PROXY_TOKEN",
+ "api_mode": "openai_chat",
+ "model": "gpt-5.4",
+ },
+ }))
+ assert len(rows) == 2
+
+
+def test_different_extra_headers_keep_distinct_rows(monkeypatch):
+ """Header-routed tenants behind one proxy URL are distinct endpoints —
+ extra_headers is part of the group identity (mirrors section 4)."""
+ rows = _user_rows(_providers(monkeypatch, {
+ "tenant-a": {
+ "name": "Tenant A",
+ "base_url": "https://proxy.example.com/v1",
+ "key_env": "PROXY_TOKEN",
+ "api_mode": "openai_chat",
+ "extra_headers": {"X-Tenant": "a"},
+ "model": "model-a",
+ },
+ "tenant-b": {
+ "name": "Tenant B",
+ "base_url": "https://proxy.example.com/v1",
+ "key_env": "PROXY_TOKEN",
+ "api_mode": "openai_chat",
+ "extra_headers": {"X-Tenant": "b"},
+ "model": "model-b",
+ },
+ }))
+ assert len(rows) == 2
+
+
+def test_list_of_dict_model_declarations_are_honored(monkeypatch):
+ """``models: [{"id": ...}]`` rows go through _declared_model_ids — the
+ grouped path must not regress that contract."""
+ rows = _user_rows(_providers(monkeypatch, {
+ "dictrows": {
+ "name": "Dict Rows",
+ "base_url": "https://dictrows.example.com/v1",
+ "key_env": "DICTROWS_TOKEN",
+ "models": [{"id": "model-x"}, {"id": "model-y"}],
+ },
+ }))
+ assert len(rows) == 1
+ assert rows[0]["models"] == ["model-x", "model-y"]
+
+
+def test_single_word_group_name_not_over_trimmed(monkeypatch):
+ """Version-token stripping only applies when the prefix keeps >= 2 words."""
+ rows = _user_rows(_providers(monkeypatch, {
+ "gpt54-a": {
+ "name": "GPT 5.4",
+ "base_url": "https://single.example.com/v1",
+ "key_env": "SINGLE_TOKEN",
+ "model": "gpt-5.4",
+ },
+ }))
+ assert rows[0]["name"] == "GPT 5.4"
+
+
+class TestFormatModelForDisplay:
+ def test_palantir_rid_stripped_to_trailing_slug(self):
+ rid = "ri.language-model-service..language-model.anthropic-claude-4-7-opus"
+ assert format_model_for_display(rid) == "anthropic-claude-4-7-opus"
+
+ def test_plain_names_pass_through(self):
+ for name in (
+ "claude-opus-4.6",
+ "gpt-5.4",
+ "meta-llama/Llama-3.3-70B-Instruct",
+ "some-model.gguf",
+ "",
+ ):
+ assert format_model_for_display(name) == name
+
+ def test_prefix_only_edge_preserved(self):
+ """A bare prefix with no trailing slug must not become empty."""
+ assert format_model_for_display("ri.language-model-service..language-model.") != ""
diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py
index afea65771c36..dfa793065f2b 100644
--- a/tests/hermes_cli/test_reasoning_full_command.py
+++ b/tests/hermes_cli/test_reasoning_full_command.py
@@ -55,7 +55,7 @@ def test_reasoning_full_sets_and_persists(tmp_path, monkeypatch):
assert saved["display"]["reasoning_full"] is True
-def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch):
+def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch, capsys):
hh = _seed_config(tmp_path, monkeypatch)
s = _Stub()
s.reasoning_full = True
@@ -64,6 +64,7 @@ def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch):
assert s.reasoning_full is False
saved = yaml.safe_load((hh / "config.yaml").read_text())
assert saved["display"]["reasoning_full"] is False
+ assert "Unknown argument" not in capsys.readouterr().out
def test_reasoning_all_is_alias_for_full(tmp_path, monkeypatch):
diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py
index 135223afb974..ad9641300df5 100644
--- a/tests/hermes_cli/test_set_config_value.py
+++ b/tests/hermes_cli/test_set_config_value.py
@@ -146,9 +146,11 @@ class TestFalsyValues:
def test_zero_routes_to_config(self, _isolated_hermes_home):
"""Setting a config key to '0' should write 0 to config.yaml."""
- set_config_value("verbose", "0")
+ # Use a real DEFAULT_CONFIG sub-key so schema validation passes — the
+ # original test used ``verbose`` which is not in the known schema.
+ set_config_value("agent.gateway_timeout", "0")
config = _read_config(_isolated_hermes_home)
- assert "verbose: 0" in config
+ assert "gateway_timeout: 0" in config
def test_config_command_rejects_missing_value(self):
"""config set with no value arg (None) should still exit."""
@@ -346,20 +348,23 @@ class TestListNavigation:
def test_deeper_nesting_through_list(self, _isolated_hermes_home):
"""Navigation path mixing dict → list → dict → scalar."""
self._write_config(_isolated_hermes_home, (
- "platforms:\n"
- " telegram:\n"
- " allowlist:\n"
+ "telegram:\n"
+ " allowlist:\n"
" - name: alice\n"
" role: admin\n"
" - name: bob\n"
" role: user\n"
))
- set_config_value("platforms.telegram.allowlist.1.role", "admin")
+ # NOTE: original test path was ``platforms.telegram.allowlist.1.role``,
+ # which #34067 schema validation correctly rejects (platform configs
+ # live at the top level, not under a ``platforms`` namespace). Use
+ # the canonical path.
+ set_config_value("telegram.allowlist.1.role", "admin")
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
- allowlist = reloaded["platforms"]["telegram"]["allowlist"]
+ allowlist = reloaded["telegram"]["allowlist"]
assert isinstance(allowlist, list)
assert allowlist[0] == {"name": "alice", "role": "admin"}
assert allowlist[1] == {"name": "bob", "role": "admin"}
@@ -398,7 +403,9 @@ class TestStringTypedConfigValues:
assert type(node) is type(expected)
def test_unknown_keys_keep_existing_coercion(self, _isolated_hermes_home):
- set_config_value("custom.enabled", "off")
+ # ``custom`` is not a known top-level key, so it now requires --force
+ # (schema validation, #34067); coercion behavior is unchanged.
+ set_config_value("custom.enabled", "off", force=True)
import yaml
saved = yaml.safe_load(_read_config(_isolated_hermes_home))
@@ -457,3 +464,169 @@ class TestSecretRedactionInDisplay:
captured = capsys.readouterr()
assert "Set model.reasoning_effort = high" in captured.out
+
+# #34067: Schema validation for unknown keys
+# ---------------------------------------------------------------------------
+
+class TestSchemaValidation:
+ """#34067: ``hermes config set`` must not report bare success for
+ unrecognized keys. The key IS written (arbitrary keys are supported —
+ top-level scalars bridge into os.environ for skills/external apps), but
+ a post-write notice warns that Hermes may never read it and suggests the
+ likely-intended path. Headline case: the plausible-but-wrong
+ ``gateway.discord.gateway_restart_notification`` (correct path:
+ ``discord.gateway_restart_notification``).
+ """
+
+ def test_unknown_top_level_key_written_with_notice(self, _isolated_hermes_home, capsys):
+ """An unknown top-level key is saved AND a notice is printed."""
+ set_config_value("totally_made_up_key", "value")
+ out = capsys.readouterr().out
+ assert "not a recognized config key" in out
+ assert "totally_made_up_key" in out
+ assert "saved anyway" in out
+ # The value WAS written.
+ assert "totally_made_up_key" in _read_config(_isolated_hermes_home)
+
+ def test_unknown_subkey_written_with_notice(self, _isolated_hermes_home, capsys):
+ """The headline #34067 path: written, but warned about — no more
+ bare success for gateway.discord.gateway_restart_notification."""
+ set_config_value("gateway.discord.gateway_restart_notification", "false")
+ out = capsys.readouterr().out
+ assert "✓ Set" in out
+ assert "not a recognized config key" in out
+
+ def test_platforms_container_is_accepted(self, _isolated_hermes_home, capsys):
+ """``platforms..`` is a valid current shape: gateway/
+ config.py resolves a top-level ``platforms`` map in addition to the
+ top-level platform blocks, so it must NOT trigger the notice."""
+ set_config_value("platforms.discord.enabled", "true")
+ content = _read_config(_isolated_hermes_home)
+ assert "enabled: true" in content
+ assert "not a recognized config key" not in capsys.readouterr().out
+
+ def test_gateway_platforms_nested_is_accepted(self, _isolated_hermes_home, capsys):
+ """Docs configure platforms under ``gateway.platforms.`` — the
+ canonical layout must validate as known (no notice)."""
+ set_config_value("gateway.platforms.my_platform.extra.token", "abc")
+ content = _read_config(_isolated_hermes_home)
+ assert "token: abc" in content
+ assert "not a recognized config key" not in capsys.readouterr().out
+
+ def test_unknown_approvals_subkey_warns_but_writes(self, _isolated_hermes_home, capsys):
+ """``approvals`` is a defined schema, so a typo'd sub-key gets the
+ notice — but is still written."""
+ set_config_value("approvals.notarealkey", "true")
+ out = capsys.readouterr().out
+ assert "not a recognized config key" in out
+ assert "notarealkey" in _read_config(_isolated_hermes_home)
+
+ def test_known_approvals_subkey_is_accepted(self, _isolated_hermes_home, capsys):
+ """Real ``approvals.*`` keys still validate silently."""
+ set_config_value("approvals.mode", "off")
+ import yaml
+ saved = yaml.safe_load(_read_config(_isolated_hermes_home))
+ assert saved["approvals"]["mode"] == "off"
+ assert "not a recognized config key" not in capsys.readouterr().out
+
+ def test_close_typo_suggests_correct_key(self, _isolated_hermes_home, capsys):
+ """Typo'd top-level keys should get a fuzzy-match suggestion."""
+ set_config_value("disco", "false")
+ out = capsys.readouterr().out
+ assert "Did you mean" in out
+ assert "discord" in out
+
+ def test_typoed_subkey_suggests_sibling(self, _isolated_hermes_home, capsys):
+ """``agent.max_turn`` should suggest ``agent.max_turns``."""
+ set_config_value("agent.max_turn", "100")
+ out = capsys.readouterr().out
+ assert "agent.max_turns" in out
+
+ def test_force_suppresses_notice(self, _isolated_hermes_home, capsys):
+ """``--force`` writes unknown keys without the notice (scripted
+ forward-compat writes)."""
+ set_config_value("brand_new_future_key", "value", force=True)
+ out = capsys.readouterr().out
+ assert "not a recognized config key" not in out
+ # And the value WAS written.
+ content = _read_config(_isolated_hermes_home)
+ assert "brand_new_future_key" in content
+
+ def test_known_top_level_key_accepted(self, _isolated_hermes_home):
+ """Sanity check: real config keys still work."""
+ set_config_value("terminal.backend", "docker")
+ content = _read_config(_isolated_hermes_home)
+ assert "backend: docker" in content
+
+ def test_known_platform_config_accepted(self, _isolated_hermes_home):
+ """Schema-defined-extensible top-level keys (platform configs) accept
+ any sub-key path because PlatformConfig has dynamic ``extra`` fields."""
+ # discord is a platform config — sub-keys accept anything.
+ set_config_value("discord.gateway_restart_notification", "false")
+ content = _read_config(_isolated_hermes_home)
+ assert "gateway_restart_notification: false" in content
+
+ def test_open_dict_mcp_servers_accepts_any_subkey(self, _isolated_hermes_home):
+ """``mcp_servers..`` must work for any
+ user-supplied server name."""
+ set_config_value("mcp_servers.my-server.command", "npx")
+ content = _read_config(_isolated_hermes_home)
+ assert "my-server" in content
+ assert "command: npx" in content
+
+
+class TestValidateConfigKey:
+ """Unit tests for the validator itself."""
+
+ @pytest.mark.parametrize("key", [
+ "model",
+ "terminal.backend",
+ "agent.max_turns",
+ "discord.gateway_restart_notification",
+ "telegram.bot_token",
+ "mcp_servers.foo.command",
+ "providers.openrouter.api_key",
+ "gateway.strict",
+ "platforms.discord.enabled",
+ "gateway.platforms.my_platform.extra.token",
+ "approvals.mode",
+ ])
+ def test_known_keys_pass(self, key):
+ from hermes_cli.config import _validate_config_key
+ is_known, _ = _validate_config_key(key)
+ assert is_known, f"Expected {key!r} to validate as known"
+
+ @pytest.mark.parametrize("key,expected_in_suggestion", [
+ ("gateway.discord.gateway_restart_notification", None), # no close suggestion
+ ("disco", "discord"),
+ ("agent.max_turn", "agent.max_turns"),
+ ])
+ def test_unknown_keys_with_suggestion(self, key, expected_in_suggestion):
+ from hermes_cli.config import _validate_config_key
+ is_known, suggestion = _validate_config_key(key)
+ assert not is_known, f"Expected {key!r} to validate as unknown"
+ if expected_in_suggestion is not None:
+ assert suggestion is not None and expected_in_suggestion in suggestion, \
+ f"Expected suggestion to contain {expected_in_suggestion!r}, got {suggestion!r}"
+
+ @pytest.mark.parametrize("key", [
+ "_test.shim_marker",
+ "_internal",
+ "_test.nested.deep.marker",
+ "_x",
+ ])
+ def test_underscore_prefixed_keys_are_accepted(self, key):
+ """Underscore-prefixed top-level keys are internal/test markers and
+ bypass schema validation. The Docker privilege-drop shim test writes
+ ``_test.shim_marker`` to probe config.yaml ownership; that must not
+ be rejected. (Regression: #34250 schema validation broke this.)"""
+ from hermes_cli.config import _validate_config_key
+ is_known, _ = _validate_config_key(key)
+ assert is_known, f"Expected underscore-prefixed {key!r} to be accepted"
+
+ def test_underscore_only_first_segment_escapes(self):
+ """The underscore escape only applies to the FIRST segment. A real
+ typo in a sub-key (e.g. agent._max_turns) is still caught."""
+ from hermes_cli.config import _validate_config_key
+ is_known, suggestion = _validate_config_key("agent._max_turns")
+ assert not is_known, "Sub-key typo under a known top-level key must still be flagged"
diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py
index 87a1e61cd688..4bcd3a6119fd 100644
--- a/tests/hermes_cli/test_tui_resume_flow.py
+++ b/tests/hermes_cli/test_tui_resume_flow.py
@@ -1,7 +1,9 @@
from argparse import Namespace
import os
from pathlib import Path
+import subprocess
import sys
+import textwrap
import types
import pytest
@@ -21,11 +23,17 @@ def _args(**overrides):
return Namespace(**base)
+def _raise_exit(rc):
+ raise SystemExit(rc)
+
+
@pytest.fixture
def main_mod(monkeypatch):
import hermes_cli.main as mod
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
+ # Reset the idempotency guard so each test starts fresh.
+ monkeypatch.setattr(mod, "_oneshot_cleanup_done", False)
return mod
@@ -353,7 +361,17 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
monkeypatch.setattr(
sys,
"argv",
- ["hermes", "-z", "hello", "--model", "gpt-test", "--provider", "openai"],
+ [
+ "hermes",
+ "-z",
+ "hello",
+ "--model",
+ "gpt-test",
+ "--provider",
+ "openai",
+ "--usage-file",
+ "usage.json",
+ ],
)
monkeypatch.setattr(
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
@@ -368,6 +386,11 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
or 17
),
)
+ monkeypatch.setattr(
+ main_mod,
+ "_exit_after_oneshot",
+ _raise_exit,
+ )
with pytest.raises(SystemExit) as exc:
main_mod._try_termux_fast_cli_launch()
@@ -379,7 +402,7 @@ def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod)
"model": "gpt-test",
"provider": "openai",
"toolsets": None,
- "usage_file": None,
+ "usage_file": "usage.json",
}
@@ -577,7 +600,17 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
import hermes_cli.config as config_mod
monkeypatch.setattr(
- sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"]
+ sys,
+ "argv",
+ [
+ "hermes",
+ "-z",
+ "hello",
+ "--toolsets",
+ "web,terminal",
+ "--usage-file",
+ "usage.json",
+ ],
)
monkeypatch.setitem(
sys.modules,
@@ -608,6 +641,11 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
or 0
),
)
+ monkeypatch.setattr(
+ main_mod,
+ "_exit_after_oneshot",
+ _raise_exit,
+ )
with pytest.raises(SystemExit) as exc:
main_mod.main()
@@ -618,10 +656,721 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
"model": None,
"provider": None,
"toolsets": "web,terminal",
- "usage_file": None,
+ "usage_file": "usage.json",
}
+def test_exit_after_oneshot_flushes_stdio_and_calls_os_exit(
+ monkeypatch, main_mod
+):
+ flushed = []
+ exits = []
+
+ class FakeStream:
+ def __init__(self, name):
+ self.name = name
+
+ def flush(self):
+ flushed.append(self.name)
+
+ def fake_exit(rc):
+ exits.append(rc)
+ raise SystemExit(rc)
+
+ monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout"))
+ monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr"))
+ monkeypatch.setattr(main_mod.os, "_exit", fake_exit)
+ monkeypatch.setattr("logging.shutdown", lambda: None)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._exit_after_oneshot(17)
+
+ assert exc.value.code == 17
+ assert exits == [17]
+ assert flushed == ["stdout", "stderr"]
+
+
+def test_exit_after_oneshot_invokes_logging_shutdown_in_order(
+ monkeypatch, main_mod
+):
+ events = []
+
+ class FakeStream:
+ def __init__(self, name):
+ self.name = name
+
+ def flush(self):
+ events.append(f"flush:{self.name}")
+
+ def fake_exit(rc):
+ events.append(f"exit:{rc}")
+ raise SystemExit(rc)
+
+ monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout"))
+ monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr"))
+ monkeypatch.setattr("logging.shutdown", lambda: events.append("shutdown"))
+ monkeypatch.setattr(main_mod.os, "_exit", fake_exit)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._exit_after_oneshot(0)
+
+ assert exc.value.code == 0
+ assert events == ["flush:stdout", "flush:stderr", "shutdown", "exit:0"]
+
+
+def test_exit_after_oneshot_exits_even_if_logging_shutdown_raises(
+ monkeypatch, main_mod
+):
+ exits = []
+
+ def fake_exit(rc):
+ exits.append(rc)
+ raise SystemExit(rc)
+
+ monkeypatch.setattr(
+ main_mod.sys, "stdout", types.SimpleNamespace(flush=lambda: None)
+ )
+ monkeypatch.setattr(
+ main_mod.sys, "stderr", types.SimpleNamespace(flush=lambda: None)
+ )
+ monkeypatch.setattr(
+ "logging.shutdown",
+ lambda: (_ for _ in ()).throw(RuntimeError("shutdown failed")),
+ )
+ monkeypatch.setattr(main_mod.os, "_exit", fake_exit)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._exit_after_oneshot(1)
+
+ assert exc.value.code == 1
+ assert exits == [1]
+
+
+def test_exit_after_oneshot_flushes_stderr_when_stdout_flush_fails(
+ monkeypatch, main_mod
+):
+ flushed = []
+ exits = []
+
+ class BadStdout:
+ def flush(self):
+ raise BrokenPipeError("pipe closed")
+
+ class FakeStderr:
+ def flush(self):
+ flushed.append("stderr")
+
+ def fake_exit(rc):
+ exits.append(rc)
+ raise SystemExit(rc)
+
+ monkeypatch.setattr(main_mod.sys, "stdout", BadStdout())
+ monkeypatch.setattr(main_mod.sys, "stderr", FakeStderr())
+ monkeypatch.setattr(main_mod.os, "_exit", fake_exit)
+ monkeypatch.setattr("logging.shutdown", lambda: None)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._exit_after_oneshot(2)
+
+ assert exc.value.code == 2
+ assert exits == [2]
+ assert flushed == ["stderr"]
+
+
+def test_exit_after_oneshot_normalizes_non_int_exit_code(monkeypatch, main_mod):
+ exits = []
+
+ def fake_exit(rc):
+ exits.append(rc)
+ raise SystemExit(rc)
+
+ monkeypatch.setattr(main_mod.os, "_exit", fake_exit)
+ monkeypatch.setattr("logging.shutdown", lambda: None)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._exit_after_oneshot(None)
+
+ assert exc.value.code == 0
+ assert exits == [0]
+
+
+def test_run_and_exit_oneshot_routes_system_exit_to_hard_exit(monkeypatch, main_mod):
+ exits = []
+
+ def fake_run_oneshot(*_args, **_kwargs):
+ raise SystemExit(2)
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=fake_run_oneshot),
+ )
+ monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None)
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [2]
+
+
+def test_run_and_exit_oneshot_routes_bare_system_exit_to_zero(monkeypatch, main_mod):
+ exits = []
+
+ def fake_run_oneshot(*_args, **_kwargs):
+ raise SystemExit
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=fake_run_oneshot),
+ )
+ monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None)
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [None]
+
+
+def test_run_and_exit_oneshot_prints_system_exit_message(
+ monkeypatch, capsys, main_mod
+):
+ exits = []
+
+ def fake_run_oneshot(*_args, **_kwargs):
+ raise SystemExit("fatal")
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=fake_run_oneshot),
+ )
+ monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None)
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [1]
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert captured.err == "fatal\n"
+
+
+def test_run_and_exit_oneshot_cleans_global_runtime_before_hard_exit(
+ monkeypatch, main_mod
+):
+ events = []
+
+ def _mod(name, **attrs):
+ fake = types.ModuleType(name)
+ for key, value in attrs.items():
+ setattr(fake, key, value)
+ return fake
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: events.append("run") or 0),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.terminal_tool",
+ _mod("tools.terminal_tool", cleanup_all_environments=lambda: events.append("terminal")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.async_delegation",
+ _mod("tools.async_delegation", interrupt_all=lambda **kw: events.append("delegation")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.browser_tool",
+ _mod("tools.browser_tool", _emergency_cleanup_all_sessions=lambda: events.append("browser")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.mcp_tool",
+ _mod("tools.mcp_tool", shutdown_mcp_servers=lambda: events.append("mcp")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "agent.auxiliary_client",
+ _mod("agent.auxiliary_client", shutdown_cached_clients=lambda: events.append("aux")),
+ )
+ monkeypatch.setattr(
+ main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}")
+ )
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert events == ["run", "terminal", "delegation", "browser", "mcp", "aux", "exit:0"]
+
+
+def test_run_and_exit_oneshot_still_exits_when_global_cleanup_raises(
+ monkeypatch, main_mod
+):
+ events = []
+
+ def _raise_mcp():
+ raise RuntimeError("mcp boom")
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.terminal_tool",
+ types.SimpleNamespace(cleanup_all_environments=lambda: events.append("terminal")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.async_delegation",
+ types.SimpleNamespace(interrupt_all=lambda **kw: events.append("delegation")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.browser_tool",
+ types.SimpleNamespace(_emergency_cleanup_all_sessions=lambda: events.append("browser")),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "tools.mcp_tool",
+ types.SimpleNamespace(shutdown_mcp_servers=_raise_mcp),
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "agent.auxiliary_client",
+ types.SimpleNamespace(shutdown_cached_clients=lambda: events.append("aux")),
+ )
+ monkeypatch.setattr(
+ main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}")
+ )
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert events == ["terminal", "delegation", "browser", "aux", "exit:0"]
+
+
+def test_run_and_exit_oneshot_hard_exits_when_cleanup_is_interrupted(
+ monkeypatch, main_mod
+):
+ def _raise_keyboard_interrupt():
+ raise KeyboardInterrupt
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0),
+ )
+ monkeypatch.setattr(
+ main_mod, "_cleanup_oneshot_runtime", _raise_keyboard_interrupt
+ )
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", _raise_exit)
+
+ with pytest.raises(SystemExit) as exc:
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exc.value.code == 0
+
+
+def test_run_and_exit_oneshot_routes_keyboard_interrupt_to_130(
+ monkeypatch, main_mod
+):
+ exits = []
+
+ def fake_run_oneshot(*_args, **_kwargs):
+ raise KeyboardInterrupt
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=fake_run_oneshot),
+ )
+ monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None)
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [130]
+
+
+def test_run_and_exit_oneshot_hard_exits_on_unexpected_exception(
+ monkeypatch, main_mod, capsys
+):
+ # ``run_oneshot`` is contracted to convert agent failures into an int and
+ # only re-raise KeyboardInterrupt / SystemExit. If it ever malfunctions and
+ # lets another exception escape, the one-shot path must still hard-exit
+ # (rc 1) rather than fall through to interpreter teardown — the exact path
+ # that SIGABRTs on AL2023.
+ exits = []
+ cleaned = []
+
+ def fake_run_oneshot(*_args, **_kwargs):
+ raise RuntimeError("run_oneshot itself blew up")
+
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=fake_run_oneshot),
+ )
+ monkeypatch.setattr(
+ main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True)
+ )
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [1]
+ # Global cleanup still runs on the defensive path — resources must not leak
+ # just because run_oneshot malfunctioned.
+ assert cleaned == [True]
+ # The failure is surfaced on stderr, never swallowed silently.
+ assert "run_oneshot itself blew up" in capsys.readouterr().err
+
+
+def test_run_and_exit_oneshot_hard_exits_when_oneshot_import_fails(
+ monkeypatch, main_mod, capsys
+):
+ import builtins
+
+ exits = []
+ cleaned = []
+ real_import = builtins.__import__
+
+ def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "hermes_cli.oneshot" and "run_oneshot" in (fromlist or ()):
+ raise RuntimeError("oneshot import blew up")
+ return real_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", fake_import)
+ monkeypatch.setattr(
+ main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True)
+ )
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hello")
+
+ assert exits == [1]
+ assert cleaned == [True]
+ assert "oneshot import blew up" in capsys.readouterr().err
+
+
+def test_oneshot_subprocess_exits_without_teardown_abort():
+ program = textwrap.dedent(
+ """
+ import hermes_cli.oneshot as oneshot
+ from hermes_cli.main import _exit_after_oneshot
+
+ oneshot._run_agent = lambda *args, **kwargs: ("ok", {"final_response": "ok"})
+ _exit_after_oneshot(oneshot.run_oneshot("hello"))
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", program],
+ cwd=Path(__file__).resolve().parents[2],
+ capture_output=True,
+ timeout=10,
+ check=False,
+ )
+
+ assert result.returncode == 0
+ assert result.stdout == b"ok\n"
+ # Don't demand byte-empty stderr — an import-time warning from the heavy
+ # CLI import chain shouldn't fail this. What matters is no crash traceback.
+ assert b"Traceback" not in result.stderr
+
+
+def test_exit_after_oneshot_bypasses_late_atexit_abort():
+ program = textwrap.dedent(
+ """
+ import atexit
+ import os
+ import sys
+ from hermes_cli.main import _exit_after_oneshot
+
+ atexit.register(os.abort)
+ sys.stdout.write("done\\n")
+ _exit_after_oneshot(0)
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", program],
+ cwd=Path(__file__).resolve().parents[2],
+ capture_output=True,
+ timeout=10,
+ check=False,
+ )
+
+ assert result.returncode == 0
+ assert result.stdout == b"done\n"
+
+
+def test_run_and_exit_oneshot_passes_through_nonzero_return(monkeypatch, main_mod):
+ # A non-zero rc from run_oneshot (e.g. provider-without-model → 2, or the
+ # empty-response guard → 1) must reach os._exit unchanged.
+ exits = []
+ monkeypatch.setitem(
+ sys.modules,
+ "hermes_cli.oneshot",
+ types.SimpleNamespace(run_oneshot=lambda *a, **k: 2),
+ )
+ monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None)
+ monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc))
+
+ main_mod._run_and_exit_oneshot("hi")
+
+ assert exits == [2]
+
+
+def test_main_oneshot_path_bypasses_late_atexit_abort():
+ # End-to-end through the real top-level ``main()`` ``-z`` path: a valid
+ # response prints, then a late atexit handler that would abort is bypassed
+ # by the hard exit, so the process reports success (#43055).
+ program = textwrap.dedent(
+ """
+ import atexit
+ import os
+ import sys
+ import types
+
+ import hermes_cli.main as main_mod
+
+ sys.argv = ["hermes", "-z", "hello"]
+ main_mod._prepare_agent_startup = lambda args: None
+
+ def _fake_run_oneshot(prompt, **kwargs):
+ print("ok")
+ return 0
+
+ sys.modules["hermes_cli.oneshot"] = types.SimpleNamespace(
+ run_oneshot=_fake_run_oneshot
+ )
+ atexit.register(os.abort)
+ main_mod.main()
+ """
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-c", program],
+ cwd=Path(__file__).resolve().parents[2],
+ capture_output=True,
+ timeout=30,
+ check=False,
+ )
+
+ assert result.returncode == 0
+ assert result.stdout == b"ok\n"
+ assert b"Traceback" not in result.stderr
+
+
+def test_oneshot_run_agent_closes_agent_after_chat(monkeypatch):
+ import hermes_cli.oneshot as oneshot_mod
+
+ closed = []
+ shutdown_messages = []
+
+ class FakeAgent:
+ def __init__(self, **_kwargs):
+ self.suppress_status_output = False
+ self.stream_delta_callback = object()
+ self.tool_gen_callback = object()
+ self._session_messages = [{"role": "user", "content": "hello"}]
+
+ def run_conversation(self, prompt, **_kwargs):
+ assert prompt == "hello"
+ return {"final_response": "done"}
+
+ def shutdown_memory_provider(self, messages=None):
+ shutdown_messages.append(messages)
+
+ def close(self):
+ closed.append(True)
+
+ monkeypatch.setitem(
+ sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)
+ )
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"model": {"default": "gpt-test", "provider": "openai"}},
+ )
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ lambda **_kwargs: {
+ "api_key": "key",
+ "base_url": "https://example.invalid",
+ "provider": "openai",
+ "api_mode": "chat_completions",
+ "credential_pool": None,
+ },
+ )
+ monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None)
+
+ assert (
+ oneshot_mod._run_agent(
+ "hello", model="gpt-test", provider="openai", use_config_toolsets=False
+ )
+ == ("done", {"final_response": "done"})
+ )
+ assert closed == [True]
+ assert shutdown_messages == [[{"role": "user", "content": "hello"}]]
+
+
+def test_oneshot_run_agent_closes_agent_when_chat_raises(monkeypatch):
+ import hermes_cli.oneshot as oneshot_mod
+
+ closed = []
+ shutdowns = []
+
+ class FakeAgent:
+ def __init__(self, **_kwargs):
+ self.suppress_status_output = False
+ self.stream_delta_callback = object()
+ self.tool_gen_callback = object()
+
+ def run_conversation(self, _prompt, **_kwargs):
+ raise RuntimeError("boom")
+
+ def shutdown_memory_provider(self, messages=None):
+ shutdowns.append(messages)
+
+ def close(self):
+ closed.append(True)
+
+ monkeypatch.setitem(
+ sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)
+ )
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"model": {"default": "gpt-test", "provider": "openai"}},
+ )
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ lambda **_kwargs: {
+ "api_key": "key",
+ "base_url": "https://example.invalid",
+ "provider": "openai",
+ "api_mode": "chat_completions",
+ "credential_pool": None,
+ },
+ )
+ monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None)
+
+ with pytest.raises(RuntimeError, match="boom"):
+ oneshot_mod._run_agent(
+ "hello", model="gpt-test", provider="openai", use_config_toolsets=False
+ )
+ assert closed == [True]
+ assert shutdowns == [None]
+
+
+def test_oneshot_run_agent_closes_session_db(monkeypatch):
+ # The one-shot exit path hard-exits via os._exit and skips finalizers, so
+ # the recall SQLite store it opens must be closed explicitly (checkpointing
+ # its WAL) rather than left to interpreter teardown.
+ import hermes_cli.oneshot as oneshot_mod
+
+ db_closed = []
+
+ class FakeAgent:
+ def __init__(self, **_kwargs):
+ self.suppress_status_output = False
+ self.stream_delta_callback = object()
+ self.tool_gen_callback = object()
+ self._session_messages = []
+
+ def run_conversation(self, _prompt, **_kwargs):
+ return {"final_response": "done"}
+
+ def shutdown_memory_provider(self, messages=None):
+ pass
+
+ def close(self):
+ pass
+
+ class FakeSessionDB:
+ def close(self):
+ db_closed.append(True)
+
+ monkeypatch.setitem(
+ sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)
+ )
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"model": {"default": "gpt-test", "provider": "openai"}},
+ )
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ lambda **_kwargs: {
+ "api_key": "key",
+ "base_url": "https://example.invalid",
+ "provider": "openai",
+ "api_mode": "chat_completions",
+ "credential_pool": None,
+ },
+ )
+ monkeypatch.setattr(
+ oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB()
+ )
+
+ assert (
+ oneshot_mod._run_agent(
+ "hello", model="gpt-test", provider="openai", use_config_toolsets=False
+ )
+ == ("done", {"final_response": "done"})
+ )
+ assert db_closed == [True]
+
+
+def test_oneshot_run_agent_closes_session_db_when_agent_init_raises(monkeypatch):
+ # The recall store is opened before AIAgent is constructed. If construction
+ # raises (bad provider/config/model), the store must still be closed — the
+ # one-shot exit hard-exits via os._exit and skips finalizers, so an
+ # un-closed connection would leave a stale WAL behind.
+ import hermes_cli.oneshot as oneshot_mod
+
+ db_closed = []
+
+ class FakeSessionDB:
+ def close(self):
+ db_closed.append(True)
+
+ class FakeAgent:
+ def __init__(self, **_kwargs):
+ raise RuntimeError("init boom")
+
+ monkeypatch.setitem(
+ sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)
+ )
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"model": {"default": "gpt-test", "provider": "openai"}},
+ )
+ monkeypatch.setattr(
+ "hermes_cli.runtime_provider.resolve_runtime_provider",
+ lambda **_kwargs: {
+ "api_key": "key",
+ "base_url": "https://example.invalid",
+ "provider": "openai",
+ "api_mode": "chat_completions",
+ "credential_pool": None,
+ },
+ )
+ monkeypatch.setattr(
+ oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB()
+ )
+
+ with pytest.raises(RuntimeError, match="init boom"):
+ oneshot_mod._run_agent(
+ "hello", model="gpt-test", provider="openai", use_config_toolsets=False
+ )
+
+ assert db_closed == [True]
+
+
def _stub_plugin_discovery(monkeypatch):
monkeypatch.setitem(
sys.modules,
@@ -935,7 +1684,11 @@ def test_launch_tui_worktree_validates_relative_python_against_final_cwd(
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
python_path = worktree / relative_python
python_path.parent.mkdir(parents=True)
- os.link(sys.executable, python_path)
+ # copy2, not os.link: tmp_path may sit on a different filesystem than
+ # the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
+ import shutil
+
+ shutil.copy2(sys.executable, python_path)
captured = {}
monkeypatch.setenv("HERMES_CWD", str(parent_cwd))
diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py
index 9014a7b1b190..8c743e71b900 100644
--- a/tests/hermes_cli/test_user_providers_model_switch.py
+++ b/tests/hermes_cli/test_user_providers_model_switch.py
@@ -477,6 +477,33 @@ def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkey
assert custom["total_models"] == 3
+def test_list_authenticated_providers_exposes_bare_direct_custom_config(monkeypatch):
+ """A direct ``model.provider=custom`` + ``model.base_url`` config has no
+ providers:/custom_providers entry, but it is still a valid runtime target
+ and must remain visible in Desktop's model picker.
+ """
+ monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
+ monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
+
+ providers = list_authenticated_providers(
+ current_provider="custom",
+ current_base_url="http://172.29.176.1:8081/v1",
+ current_model="gpt-5.4",
+ user_providers={},
+ custom_providers=[],
+ max_models=50,
+ )
+
+ custom = next((p for p in providers if p["slug"] == "custom"), None)
+ assert custom is not None
+ assert custom["is_current"] is True
+ # main surfaces the bare direct-config row via the "model-config" source
+ # (with live model discovery); the desktop CRUD view builds on that same row.
+ assert custom["source"] == "model-config"
+ assert custom["api_url"] == "http://172.29.176.1:8081/v1"
+ assert "gpt-5.4" in custom["models"]
+
+
def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch):
"""When the same slug appears in both ``providers:`` dict and
``custom_providers:`` list, emit exactly one row (providers: dict wins
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 5353ef0f4ba2..404a06ae8b84 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -3989,6 +3989,18 @@ class TestWebServerEndpoints:
assert "api_key" not in out
assert "api_mode" not in out
+ # switching providers when the stale secret lives under the legacy
+ # ``api`` alias only (no api_key) → it must be cleared too. The resolver
+ # reads ``model.api`` as a key, so leaving it behind keeps a secret in
+ # config.yaml that contaminates the next custom resolution.
+ out = _apply_main_model_assignment(
+ {"provider": "custom", "api": "sk-legacy-stale", "base_url": "http://endpoint-a/v1"},
+ "openrouter",
+ "m",
+ )
+ assert "api" not in out
+ assert "api_key" not in out
+
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
"""Model discovery must tolerate the common /v1/models shapes and
never raise (so a slightly non-standard local endpoint still works)."""
@@ -4188,6 +4200,301 @@ class TestWebServerEndpoints:
assert model_cfg["provider"] == "openrouter"
assert model_cfg.get("base_url", "") == ""
+ def test_custom_endpoints_list_includes_direct_custom_config(self):
+ """A bare model.provider=custom config should show up in Desktop even
+ before the user has materialized it under providers.
+ """
+ from hermes_cli.config import save_config
+
+ save_config({
+ "model": {
+ "provider": "custom",
+ "default": "gpt-5.4",
+ "base_url": "http://127.0.0.1:8081/v1",
+ "api_key": "sk-local",
+ },
+ "providers": {},
+ })
+
+ resp = self.client.get("/api/providers/custom-endpoints")
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["current"] == {
+ "provider": "custom",
+ "model": "gpt-5.4",
+ "base_url": "http://127.0.0.1:8081/v1",
+ }
+ assert data["endpoints"][0]["id"] == "custom"
+ assert data["endpoints"][0]["source"] == "direct-config"
+ assert data["endpoints"][0]["has_api_key"] is True
+
+ def test_custom_endpoint_upsert_persists_provider_and_sets_default(self):
+ """Desktop can persist an OpenAI-compatible proxy in providers and make
+ it the default for new chats.
+ """
+ from hermes_cli.config import load_config
+
+ resp = self.client.post(
+ "/api/providers/custom-endpoints",
+ json={
+ "id": "axet-proxy",
+ "name": "Axet Proxy",
+ "base_url": "http://127.0.0.1:8081/v1/",
+ "model": "gpt-5.4",
+ "api_key": "sk-local",
+ "context_length": 262144,
+ "make_default": True,
+ },
+ )
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ assert data["id"] == "axet-proxy"
+ endpoint = next(e for e in data["endpoints"] if e["id"] == "axet-proxy")
+ assert endpoint["base_url"] == "http://127.0.0.1:8081/v1"
+ assert endpoint["model"] == "gpt-5.4"
+ assert endpoint["is_current"] is True
+
+ cfg = load_config()
+ assert cfg["providers"]["axet-proxy"]["base_url"] == "http://127.0.0.1:8081/v1"
+ assert cfg["providers"]["axet-proxy"]["models"]["gpt-5.4"]["context_length"] == 262144
+ assert cfg["model"]["provider"] == "axet-proxy"
+ assert cfg["model"]["default"] == "gpt-5.4"
+ assert cfg["model"]["base_url"] == "http://127.0.0.1:8081/v1"
+
+ def _seed_custom_provider_with_key(self):
+ from hermes_cli.config import load_config, save_config
+
+ cfg = load_config()
+ cfg["providers"] = {
+ "acme": {
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/m1",
+ "api_key": "sk-stored-old",
+ "models": {"acme/m1": {}},
+ }
+ }
+ save_config(cfg)
+
+ def test_set_model_main_honors_an_explicitly_supplied_api_key(self):
+ """A key in the request must win over the provider entry's stored one.
+
+ The entry-key fallback exists so switching to a configured provider
+ picks up its credential. Applying it unconditionally discards a key the
+ caller is rotating in — and ``model.api_key`` outranks the environment
+ at client construction (#62269), so the stale key keeps authenticating
+ while the UI reports the change saved.
+ """
+ from hermes_cli.config import load_config
+
+ self._seed_custom_provider_with_key()
+
+ resp = self.client.post(
+ "/api/model/set",
+ json={
+ "scope": "main",
+ "provider": "acme",
+ "model": "acme/m1",
+ "api_key": "sk-new-rotated",
+ },
+ )
+ assert resp.status_code == 200
+ assert load_config()["model"]["api_key"] == "sk-new-rotated"
+
+ def test_set_model_main_falls_back_to_the_provider_entry_key(self):
+ """With no key in the request the stored one is still adopted."""
+ from hermes_cli.config import load_config
+
+ self._seed_custom_provider_with_key()
+
+ resp = self.client.post(
+ "/api/model/set",
+ json={"scope": "main", "provider": "acme", "model": "acme/m1"},
+ )
+ assert resp.status_code == 200
+ model_cfg = load_config()["model"]
+ assert model_cfg["api_key"] == "sk-stored-old"
+ # The sibling base_url fill is unaffected.
+ assert model_cfg["base_url"] == "https://llm.acme.corp/v1"
+
+ def test_custom_endpoint_edit_preserves_hand_written_provider_fields(self):
+ """The panel edits a few fields; it does not own the whole entry.
+
+ A ``providers.`` block can carry keys the dashboard has no field
+ for — ``api_mode``, ``key_env``, ``extra_headers`` (which may carry
+ credentials), ``request_overrides``. Rebuilding the entry from scratch
+ on an unrelated edit silently dropped all of them, leaving a provider
+ that no longer authenticates or speaks the right protocol.
+ """
+ from hermes_cli.config import load_config, save_config
+
+ cfg = load_config()
+ cfg["providers"] = {
+ "acme": {
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/model-1",
+ "api_mode": "responses",
+ "key_env": "ACME_API_KEY",
+ "extra_headers": {"X-Org-Id": "org_123"},
+ "request_overrides": {"reasoning_effort": "high"},
+ "models": {
+ "acme/model-1": {"context_length": 200000},
+ "acme/model-2": {"context_length": 400000},
+ },
+ }
+ }
+ save_config(cfg)
+
+ # The user opens the panel and only switches the default model.
+ resp = self.client.post(
+ "/api/providers/custom-endpoints",
+ json={
+ "id": "acme",
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/model-2",
+ },
+ )
+ assert resp.status_code == 200
+
+ entry = load_config()["providers"]["acme"]
+ assert entry["api_mode"] == "responses"
+ assert entry["key_env"] == "ACME_API_KEY"
+ assert entry["extra_headers"] == {"X-Org-Id": "org_123"}
+ assert entry["request_overrides"] == {"reasoning_effort": "high"}
+ # The edit still applies.
+ assert entry["model"] == "acme/model-2"
+
+ def test_custom_endpoint_edit_keeps_the_other_models(self):
+ """The panel names one default model; it doesn't enumerate the catalogue."""
+ from hermes_cli.config import load_config, save_config
+
+ cfg = load_config()
+ cfg["providers"] = {
+ "acme": {
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/model-1",
+ "models": {
+ "acme/model-1": {"context_length": 200000},
+ "acme/model-2": {"context_length": 400000},
+ },
+ }
+ }
+ save_config(cfg)
+
+ self.client.post(
+ "/api/providers/custom-endpoints",
+ json={
+ "id": "acme",
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/model-2",
+ },
+ )
+
+ models = load_config()["providers"]["acme"]["models"]
+ assert sorted(models) == ["acme/model-1", "acme/model-2"]
+ assert models["acme/model-1"]["context_length"] == 200000
+
+ def test_deleting_the_active_custom_endpoint_clears_its_model_mirror(self):
+ """Deleting an endpoint must not leave its key running the agent.
+
+ ``activate`` copies the endpoint's base_url + api_key onto ``model``,
+ and ``model.api_key`` outranks the environment at client construction
+ (#62269). Without clearing that mirror the agent keeps authenticating
+ to the deleted host with the deleted key, and the key the operator
+ just removed through the dashboard stays in config.yaml.
+ """
+ from hermes_cli.config import load_config
+
+ self.client.post(
+ "/api/providers/custom-endpoints",
+ json={
+ "id": "acme",
+ "name": "Acme",
+ "base_url": "https://llm.acme.corp/v1",
+ "model": "acme/model-1",
+ "api_key": "sk-acme-secret",
+ },
+ )
+ assert self.client.post(
+ "/api/providers/custom-endpoints/acme/activate", json={}
+ ).status_code == 200
+
+ cfg = load_config()
+ assert cfg["model"]["api_key"] == "sk-acme-secret"
+
+ assert self.client.request(
+ "DELETE", "/api/providers/custom-endpoints/acme"
+ ).status_code == 200
+
+ cfg = load_config()
+ assert "acme" not in (cfg.get("providers") or {})
+ model_cfg = cfg.get("model") or {}
+ assert not model_cfg.get("api_key"), "deleted endpoint's key still in config.yaml"
+ assert not model_cfg.get("base_url"), "deleted endpoint's host still routed to"
+ assert not model_cfg.get("provider")
+
+ def test_deleting_an_inactive_custom_endpoint_leaves_the_active_one_alone(self):
+ """Only the mirror of the DELETED provider is scrubbed."""
+ from hermes_cli.config import load_config
+
+ for name, key in (("acme", "sk-acme"), ("other", "sk-other")):
+ self.client.post(
+ "/api/providers/custom-endpoints",
+ json={
+ "id": name,
+ "name": name,
+ "base_url": f"https://llm.{name}.corp/v1",
+ "model": f"{name}/m",
+ "api_key": key,
+ },
+ )
+
+ self.client.post("/api/providers/custom-endpoints/other/activate", json={})
+ self.client.request("DELETE", "/api/providers/custom-endpoints/acme")
+
+ model_cfg = load_config().get("model") or {}
+ assert model_cfg.get("provider") == "other"
+ assert model_cfg.get("api_key") == "sk-other"
+ assert model_cfg.get("base_url") == "https://llm.other.corp/v1"
+
+ def test_set_model_main_preserves_base_url_for_named_custom_provider(self):
+ """Selecting a named custom endpoint from the Desktop model picker
+ should keep its endpoint URL attached to model config.
+ """
+ from hermes_cli.config import load_config, save_config
+
+ save_config({
+ "model": {"provider": "nous", "default": "hermes-4"},
+ "providers": {
+ "axet-proxy": {
+ "name": "Axet Proxy",
+ "base_url": "http://127.0.0.1:8081/v1",
+ "api_key": "sk-local",
+ "model": "gpt-5.4",
+ "models": {"gpt-5.4": {}},
+ }
+ },
+ })
+
+ resp = self.client.post(
+ "/api/model/set",
+ json={"scope": "main", "provider": "axet-proxy", "model": "gpt-5.4"},
+ )
+
+ assert resp.status_code == 200
+ model_cfg = load_config()["model"]
+ assert model_cfg["provider"] == "axet-proxy"
+ assert model_cfg["default"] == "gpt-5.4"
+ assert model_cfg["base_url"] == "http://127.0.0.1:8081/v1"
+ assert model_cfg["api_key"] == "sk-local"
+
def test_set_model_main_gateway_failure_does_not_block_save(self, monkeypatch):
"""A Portal/gateway hiccup must never prevent saving the model."""
import hermes_cli.nous_subscription as ns
@@ -5504,6 +5811,26 @@ class TestNewEndpoints:
by_name = {p["name"]: p for p in resp.json()["providers"]}
assert by_name["ElevenLabs"]["status"] == "ready"
+ def test_get_toolset_config_tts_rows_carry_provider_key(self):
+ """TTS provider rows surface their tts_provider config key.
+
+ The desktop Capabilities panel renders the provider's voice/model
+ config fields (tts..*) inline; without the key it can only show
+ API keys. Every built-in TTS row declares one.
+ """
+ resp = self.client.get("/api/tools/toolsets/tts/config")
+ assert resp.status_code == 200
+ providers = resp.json()["providers"]
+ assert providers
+ for prov in providers:
+ assert prov.get("tts_provider"), f"row {prov['name']!r} missing tts_provider"
+ by_name = {p["name"]: p for p in providers}
+ assert by_name["OpenAI TTS"]["tts_provider"] == "openai"
+ assert by_name["Microsoft Edge TTS"]["tts_provider"] == "edge"
+ # Non-TTS toolsets must not grow the field.
+ web = self.client.get("/api/tools/toolsets/web/config").json()
+ assert all("tts_provider" not in p for p in web["providers"])
+
def test_get_toolset_config_reflects_selected_provider(self):
"""Selecting a provider is reflected in the next /config read.
@@ -8103,7 +8430,9 @@ class TestPtyWebSocket:
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
python_path = tmp_path / relative_python
python_path.parent.mkdir(parents=True)
- os.link(sys.executable, python_path)
+ # copy2, not os.link: tmp_path may sit on a different filesystem than
+ # the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
+ shutil.copy2(sys.executable, python_path)
monkeypatch.setenv("HERMES_CWD", str(tmp_path))
monkeypatch.setenv("HERMES_PYTHON", str(relative_python))
monkeypatch.setattr(
@@ -8125,7 +8454,9 @@ class TestPtyWebSocket:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
executable = bin_dir / command
- os.link(sys.executable, executable)
+ # copy2, not os.link: tmp_path may sit on a different filesystem than
+ # the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
+ shutil.copy2(sys.executable, executable)
env = {
"HERMES_CWD": str(tmp_path),
"HERMES_PYTHON": command,
diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py
index abcdbb681fa4..2c5d8bfc71ec 100644
--- a/tests/hermes_cli/test_web_server_cron_profiles.py
+++ b/tests/hermes_cli/test_web_server_cron_profiles.py
@@ -735,3 +735,53 @@ async def test_cron_profile_validation_errors(isolated_profiles):
with pytest.raises(HTTPException) as missing:
await web_server.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_create_cron_job_without_profile_uses_backend_own_profile(
+ isolated_profiles, monkeypatch
+):
+ """A pool backend scoped to a named profile must not default creates to
+ ``~/.hermes`` when the request carries no explicit ``profile`` (the
+ Desktop app's pre-profileScoped clients sent none)."""
+ from hermes_cli import web_server
+
+ monkeypatch.setenv(
+ "HERMES_HOME", str(isolated_profiles["worker_alpha"])
+ )
+
+ job = await web_server.create_cron_job(
+ web_server.CronJobCreate(
+ prompt="runs in my own profile",
+ schedule="every 1h",
+ name="own-profile-job",
+ ),
+ profile=None,
+ )
+
+ assert job["profile"] == "worker_alpha"
+ assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
+ assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
+
+
+@pytest.mark.asyncio
+async def test_create_cron_job_without_profile_defaults_when_unscoped(
+ isolated_profiles, monkeypatch
+):
+ """HERMES_HOME at the default home (or unrecognized) keeps the legacy
+ ``default`` fallback."""
+ from hermes_cli import web_server
+
+ monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
+
+ job = await web_server.create_cron_job(
+ web_server.CronJobCreate(
+ prompt="runs in default",
+ schedule="every 1h",
+ name="default-job",
+ ),
+ profile=None,
+ )
+
+ assert job["profile"] == "default"
+ assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py
index c0dc890433f5..6416ef88b205 100644
--- a/tests/plugins/memory/test_supermemory_provider.py
+++ b/tests/plugins/memory/test_supermemory_provider.py
@@ -17,11 +17,13 @@ from plugins.memory.supermemory import (
class FakeClient:
- def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
+ def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
+ base_url: str = ""):
self.api_key = api_key
self.timeout = timeout
self.container_tag = container_tag
self.search_mode = search_mode
+ self.base_url = base_url
self.add_calls = []
self.search_results = []
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
@@ -373,6 +375,107 @@ def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
assert p._search_mode == "hybrid"
+# -- Base URL tests -------------------------------------------------------------
+
+
+def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
+ """Without config or env override, the client targets api.supermemory.ai."""
+ monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
+ monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False)
+ monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
+ p = SupermemoryMemoryProvider()
+ p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
+ assert p._base_url == "https://api.supermemory.ai"
+ assert p._client.base_url == "https://api.supermemory.ai"
+
+
+def test_base_url_env_var_override(monkeypatch, tmp_path):
+ """SUPERMEMORY_BASE_URL points the provider at a self-hosted server (trailing slash stripped)."""
+ monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
+ monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://localhost:6767/")
+ monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
+ p = SupermemoryMemoryProvider()
+ p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
+ assert p._base_url == "http://localhost:6767"
+ assert p._client.base_url == "http://localhost:6767"
+
+
+def test_base_url_config_overrides_env(monkeypatch, tmp_path):
+ """base_url in supermemory.json takes precedence over the env var."""
+ monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
+ monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://env-host:6767")
+ monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
+ _save_supermemory_config({"base_url": "http://config-host:6767/"}, str(tmp_path))
+ p = SupermemoryMemoryProvider()
+ p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
+ assert p._base_url == "http://config-host:6767"
+ assert p._client.base_url == "http://config-host:6767"
+
+
+def test_client_passes_custom_base_url_to_sdk(monkeypatch):
+ """SDK operations and raw conversation ingest share one normalized base URL."""
+ import sys
+ import types
+
+ from plugins.memory.supermemory import _SupermemoryClient
+
+ captured = {}
+
+ class StubSupermemory:
+ def __init__(self, **kwargs):
+ captured.update(kwargs)
+
+ module = types.ModuleType("supermemory")
+ module.Supermemory = StubSupermemory
+ monkeypatch.setitem(sys.modules, "supermemory", module)
+ monkeypatch.setattr("tools.lazy_deps.ensure", lambda *args, **kwargs: None)
+
+ client = _SupermemoryClient(
+ api_key="test-key",
+ timeout=1.0,
+ container_tag="hermes",
+ base_url="http://localhost:6767/",
+ )
+
+ assert client._base_url == "http://localhost:6767"
+ assert captured["base_url"] == "http://localhost:6767"
+
+
+@pytest.mark.parametrize(
+ ("base_url", "expected_url"),
+ [
+ ("https://api.supermemory.ai", "https://api.supermemory.ai/v4/conversations"),
+ ("http://localhost:6767", "http://localhost:6767/v4/conversations"),
+ ],
+)
+def test_ingest_conversation_uses_client_base_url(monkeypatch, base_url, expected_url):
+ """Raw conversation ingest follows the same endpoint as SDK operations."""
+ from plugins.memory.supermemory import _SupermemoryClient
+
+ client = _SupermemoryClient.__new__(_SupermemoryClient)
+ client._api_key = "test-key"
+ client._container_tag = "hermes"
+ client._timeout = 1.0
+ client._base_url = base_url
+
+ captured = {}
+
+ class _FakeResponse:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return False
+
+ def fake_urlopen(req, timeout=None):
+ captured["url"] = req.full_url
+ return _FakeResponse()
+
+ monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
+ client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
+ assert captured["url"] == expected_url
+
+
# -- Multi-container tests ----------------------------------------------------
@@ -533,9 +636,13 @@ def _stub_supermemory_importable(monkeypatch):
def test_probe_supermemory_connection_success(monkeypatch, tmp_path):
_stub_supermemory_importable(monkeypatch)
- monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
+ seen_base_urls = []
class CountingClient(FakeClient):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ seen_base_urls.append(self.base_url)
+
def get_profile(self, query=None, *, container_tag=None):
return {
"static": ["Prefers TypeScript"],
@@ -544,10 +651,13 @@ def test_probe_supermemory_connection_success(monkeypatch, tmp_path):
}
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", CountingClient)
+ monkeypatch.setenv("SUPERMEMORY_BASE_URL", "http://env-host:6767")
+ _save_supermemory_config({"base_url": "http://localhost:6767/"}, str(tmp_path))
status = _probe_supermemory_connection("test-key", str(tmp_path))
assert status["ok"] is True
assert status["profile_facts"] == 2
assert status["auto_recall"] is True
+ assert seen_base_urls == ["http://localhost:6767"]
def test_probe_supermemory_connection_client_error(monkeypatch, tmp_path):
diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py
index 206aba46a6fa..302c33bc4649 100644
--- a/tests/run_agent/test_413_compression.py
+++ b/tests/run_agent/test_413_compression.py
@@ -568,13 +568,19 @@ class TestPreflightCompression:
events = []
agent.status_callback = lambda ev, msg: events.append((ev, msg))
- def _fake_compress(messages, current_tokens=None, focus_topic=None):
+ def _fake_compress(
+ messages,
+ current_tokens=None,
+ focus_topic=None,
+ force=False,
+ memory_context="",
+ ):
events.append(("compress", "started"))
return [{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}]
with (
patch.object(agent.context_compressor, "compress", side_effect=_fake_compress),
- patch.object(agent, "_build_system_prompt", return_value="new system prompt"),
+ patch.object(agent, "_build_system_prompt", return_value="new system prompt") as build_prompt,
patch("run_agent.estimate_request_tokens_rough", return_value=42),
):
compressed, new_system_prompt = agent._compress_context(
@@ -591,11 +597,141 @@ class TestPreflightCompression:
{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"},
{"role": "user", "content": "hello"},
]
- assert new_system_prompt == "new system prompt"
+ assert new_system_prompt == "You are helpful."
+ build_prompt.assert_not_called()
assert events[0][0] == "lifecycle"
assert "Compacting context" in events[0][1]
assert events[1] == ("compress", "started")
+ def test_compression_reuses_cached_prompt_when_memory_snapshot_is_unchanged(self, agent):
+ """A memory reload without new injected text must keep the cache prefix."""
+ agent.compression_enabled = False
+ agent._memory_enabled = True
+ agent._user_profile_enabled = False
+ agent._memory_manager = None
+ agent._cached_system_prompt = (
+ "cached system prompt\n\nsame facts"
+ )
+ memory_store = MagicMock()
+ memory_store.format_for_system_prompt.return_value = "same facts"
+ agent._memory_store = memory_store
+
+ with (
+ patch.object(
+ agent.context_compressor,
+ "compress",
+ return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
+ ),
+ patch.object(agent, "_build_system_prompt") as build_prompt,
+ ):
+ _, new_system_prompt = agent._compress_context(
+ [{"role": "user", "content": "hello"}],
+ "system prompt",
+ approx_tokens=1234,
+ )
+
+ assert new_system_prompt is agent._cached_system_prompt
+ assert new_system_prompt == "cached system prompt\n\nsame facts"
+ build_prompt.assert_not_called()
+ memory_store.load_from_disk.assert_called_once()
+
+ def test_compression_rebuilds_prompt_when_memory_snapshot_changes(self, agent):
+ """A changed memory block must be reflected in the next model request."""
+ agent.compression_enabled = False
+ agent._memory_enabled = True
+ agent._user_profile_enabled = False
+ agent._memory_manager = None
+ agent._cached_system_prompt = (
+ "cached system prompt\n\nold facts"
+ )
+ memory_store = MagicMock()
+ memory_store.format_for_system_prompt.return_value = "new facts"
+ agent._memory_store = memory_store
+
+ with (
+ patch.object(
+ agent.context_compressor,
+ "compress",
+ return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
+ ),
+ patch.object(agent, "_build_system_prompt", return_value="rebuilt system prompt") as build_prompt,
+ ):
+ _, new_system_prompt = agent._compress_context(
+ [{"role": "user", "content": "hello"}],
+ "system prompt",
+ approx_tokens=1234,
+ )
+
+ assert new_system_prompt == "rebuilt system prompt"
+ build_prompt.assert_called_once_with("system prompt")
+ memory_store.load_from_disk.assert_called_once()
+
+ def test_compression_rebuilds_when_restored_prompt_predates_memory_write(self, agent):
+ """Gateway fresh-agent path: a session-DB-restored prompt built with OLD
+ memory must be rebuilt even though the in-memory snapshot is identical
+ before and after the disk reload (the fresh MemoryStore already
+ absorbed the mid-session write at init). Guards the containment check
+ against regressing to before/after snapshot equality."""
+ agent.compression_enabled = False
+ agent._memory_enabled = True
+ agent._user_profile_enabled = False
+ agent._memory_manager = None
+ # Restored from SessionDB in an earlier process — built with fact A only.
+ agent._cached_system_prompt = "system prompt\n\nfact A"
+ memory_store = MagicMock()
+ # Fresh store loaded fact A + fact B at agent init; stable across reload.
+ memory_store.format_for_system_prompt.return_value = "fact A\nfact B"
+ agent._memory_store = memory_store
+
+ with (
+ patch.object(
+ agent.context_compressor,
+ "compress",
+ return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
+ ),
+ patch.object(agent, "_build_system_prompt", return_value="rebuilt with fact B") as build_prompt,
+ ):
+ _, new_system_prompt = agent._compress_context(
+ [{"role": "user", "content": "hello"}],
+ "system prompt",
+ approx_tokens=1234,
+ )
+
+ assert new_system_prompt == "rebuilt with fact B"
+ build_prompt.assert_called_once_with("system prompt")
+
+ def test_compression_rebuilds_when_prompt_has_leftover_block_for_emptied_memory(self, agent):
+ """A prompt still carrying a memory block after all entries were
+ removed must be rebuilt — empty current blocks are vacuously
+ 'contained', so the leftover-header check has to catch this."""
+ agent.compression_enabled = False
+ agent._memory_enabled = True
+ agent._user_profile_enabled = False
+ agent._memory_manager = None
+ agent._cached_system_prompt = (
+ "system prompt\n\nMEMORY (your personal notes) [1% — 10/2,200 chars]\nold fact"
+ )
+ memory_store = MagicMock()
+ memory_store.format_for_system_prompt.return_value = None # emptied
+ agent._memory_store = memory_store
+
+ with (
+ patch.object(
+ agent.context_compressor,
+ "compress",
+ return_value=[{"role": "user", "content": f"{SUMMARY_PREFIX}\nPrevious conversation"}],
+ ),
+ patch.object(agent, "_build_system_prompt", return_value="rebuilt without memory") as build_prompt,
+ ):
+ _, new_system_prompt = agent._compress_context(
+ [{"role": "user", "content": "hello"}],
+ "system prompt",
+ approx_tokens=1234,
+ )
+
+ assert new_system_prompt == "rebuilt without memory"
+ build_prompt.assert_called_once_with("system prompt")
+
def test_preflight_compresses_oversized_history(self, agent):
"""When loaded history exceeds the model's context threshold, compress before API call."""
agent.compression_enabled = True
diff --git a/tests/run_agent/test_partial_stream_finish_reason.py b/tests/run_agent/test_partial_stream_finish_reason.py
index 62b40d8091ff..6a31efe27a00 100644
--- a/tests/run_agent/test_partial_stream_finish_reason.py
+++ b/tests/run_agent/test_partial_stream_finish_reason.py
@@ -230,6 +230,45 @@ class TestCleanStreamEndMidToolCall:
assert response.id.startswith("stream-")
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
+ @patch("run_agent.AIAgent._create_request_openai_client")
+ @patch("run_agent.AIAgent._close_request_openai_client")
+ def test_no_finish_reason_text_only_routes_to_stub(
+ self, _mock_close, mock_create, monkeypatch,
+ ):
+ """A clean stream-end with no finish_reason after text-only
+ delivery must route through the partial-stream-stub path so the
+ conversation loop continues instead of silently accepting
+ truncated text as a complete response (#32086)."""
+
+ def _clean_ending_stream():
+ yield _make_stream_chunk(content="Let me compare the ")
+ yield _make_stream_chunk(content="vision configs:")
+ # falls off the end — clean close, no terminator
+
+ mock_client = MagicMock()
+ mock_client.chat.completions.create.side_effect = (
+ lambda *a, **kw: _clean_ending_stream()
+ )
+ mock_create.return_value = mock_client
+
+ agent = _make_agent()
+ agent._fire_stream_delta = lambda text: None
+
+ response = agent._interruptible_streaming_api_call({})
+
+ assert response.id == PARTIAL_STREAM_STUB_ID, (
+ "A clean stream-end with no finish_reason after text-only "
+ "delivery must be tagged as a partial-stream stub, not "
+ "silently accepted as complete (#32086)."
+ )
+ assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
+ assert response.choices[0].message.content == "Let me compare the vision configs:"
+ assert response.choices[0].message.tool_calls is None
+ assert getattr(response, "_dropped_tool_names", None) is None, (
+ "Text-only drops must not carry dropped tool names — there "
+ "were no tool calls in flight."
+ )
+
# ── Length-continuation prompt branching ──────────────────────────────────
diff --git a/tests/run_agent/test_pre_compress_memory_context.py b/tests/run_agent/test_pre_compress_memory_context.py
new file mode 100644
index 000000000000..b5a33fa8c27b
--- /dev/null
+++ b/tests/run_agent/test_pre_compress_memory_context.py
@@ -0,0 +1,230 @@
+"""Behavior contracts for the pre-compression memory-context handoff."""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+
+def _make_agent(memory_manager, compressor):
+ from run_agent import AIAgent
+
+ agent = AIAgent(
+ api_key="test-key",
+ provider="openrouter",
+ api_mode="chat_completions",
+ base_url="https://openrouter.ai/api/v1",
+ model="test/model",
+ quiet_mode=True,
+ session_db=None,
+ session_id="test-session",
+ skip_context_files=True,
+ skip_memory=True,
+ )
+
+ agent._memory_manager = memory_manager
+ agent.context_compressor = compressor
+ agent._compression_feasibility_checked = True
+ agent._invalidate_system_prompt = lambda: None
+ agent._build_system_prompt = lambda _message: "new-system-prompt"
+ return agent
+
+
+def _messages():
+ return [{"role": "user", "content": f"message {i}"} for i in range(6)]
+
+
+def _configure_engine_state(engine):
+ engine.compression_count = 1
+ engine.last_prompt_tokens = 0
+ engine.last_completion_tokens = 0
+ engine._last_summary_error = None
+ engine._last_compress_aborted = False
+ engine._last_aux_model_failure_model = None
+ engine._last_aux_model_failure_error = None
+
+
+def test_on_pre_compress_result_reaches_compressor_with_existing_options():
+ manager = MagicMock()
+ manager.on_pre_compress.return_value = "Checkpoint id: ctx-orchestrator"
+ received = {}
+ compressor = MagicMock()
+
+ def capture_compress(
+ incoming,
+ current_tokens=None,
+ focus_topic=None,
+ force=False,
+ memory_context="",
+ ):
+ received.update(
+ current_tokens=current_tokens,
+ focus_topic=focus_topic,
+ force=force,
+ memory_context=memory_context,
+ )
+ return [incoming[0], incoming[-1]]
+
+ compressor.compress.side_effect = capture_compress
+ _configure_engine_state(compressor)
+ agent = _make_agent(manager, compressor)
+ messages = _messages()
+
+ agent._compress_context(
+ messages,
+ "sys",
+ approx_tokens=100_000,
+ focus_topic="checkpoint continuity",
+ force=True,
+ )
+
+ manager.on_pre_compress.assert_called_once_with(messages)
+ assert received == {
+ "current_tokens": 100_000,
+ "focus_topic": "checkpoint continuity",
+ "force": True,
+ "memory_context": "Checkpoint id: ctx-orchestrator",
+ }
+
+
+def test_legacy_engine_receives_only_supported_compression_arguments():
+ manager = MagicMock()
+ manager.on_pre_compress.return_value = "Checkpoint id: unsupported-by-legacy"
+ calls = []
+
+ class StrictLegacyEngine:
+ def compress(self, messages, current_tokens=None):
+ calls.append(current_tokens)
+ return [messages[0], messages[-1]]
+
+ engine = StrictLegacyEngine()
+ _configure_engine_state(engine)
+ agent = _make_agent(manager, engine)
+
+ compressed, _prompt = agent._compress_context(
+ _messages(),
+ "sys",
+ approx_tokens=100_000,
+ focus_topic="unsupported focus",
+ force=True,
+ )
+
+ assert len(compressed) == 2
+ assert calls == [100_000]
+
+
+def test_provider_context_is_strictly_sanitized_before_plugin_engine(monkeypatch):
+ prefix_secret = "sk-" + "a" * 30
+ query_secret = "opaque-query-secret"
+ userinfo_value = "opaque-userinfo-value"
+ fragment_secret = "FRAG_SECRET"
+ relative_secret = "REL_SECRET"
+ encoded_key_secret = "ENC_SECRET"
+ hyphen_client_secret = "HYPHEN_CLIENT_SECRET"
+ hyphen_access_secret = "HYPHEN_ACCESS_SECRET"
+ hyphen_api_secret = "HYPHEN_API_SECRET"
+ encoded_hyphen_secret = "ENCODED_HYPHEN_SECRET"
+ network_userinfo_secret = "NET_SECRET"
+ manager = MagicMock()
+ manager.on_pre_compress.return_value = (
+ f"api key: {prefix_secret}\n"
+ f"callback: https://example.test/cb?access_token={query_secret}&state=ok\n"
+ f"endpoint: https://user:{userinfo_value}@example.test/private\n"
+ f"fragment: https://x.test/#access_token={fragment_secret}&view=public\n"
+ f"relative: /resume?token={relative_secret}&view=public\n"
+ f"encoded: https://x.test/cb?client%5Fsecret={encoded_key_secret}&view=public\n"
+ f"hyphen-client: /resume?client-secret={hyphen_client_secret}&view=public\n"
+ f"hyphen-access: /resume?Access-Token={hyphen_access_secret}&view=public\n"
+ f"hyphen-api: /resume?api-key={hyphen_api_secret}&view=public\n"
+ f"encoded-hyphen: /resume?client%2Dsecret={encoded_hyphen_secret}&view=public\n"
+ f"network: //user:{network_userinfo_secret}@x.test/path"
+ )
+ received = []
+ compressor = MagicMock()
+
+ def capture_compress(messages, current_tokens=None, memory_context="", **_kwargs):
+ received.append(memory_context)
+ return [messages[0], messages[-1]]
+
+ compressor.compress.side_effect = capture_compress
+ _configure_engine_state(compressor)
+ agent = _make_agent(manager, compressor)
+
+ # Provider-to-engine handoff is an external-LLM egress boundary, so it
+ # remains strict even when display/log redaction was explicitly disabled.
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", False)
+ agent._compress_context(_messages(), "sys", approx_tokens=100_000)
+
+ assert len(received) == 1
+ context = received[0]
+ assert prefix_secret not in context
+ assert query_secret not in context
+ assert userinfo_value not in context
+ assert fragment_secret not in context
+ assert relative_secret not in context
+ assert encoded_key_secret not in context
+ assert hyphen_client_secret not in context
+ assert hyphen_access_secret not in context
+ assert hyphen_api_secret not in context
+ assert encoded_hyphen_secret not in context
+ assert network_userinfo_secret not in context
+ assert "access_token=***" in context
+ assert "https://user:***@example.test/private" in context
+ assert "https://x.test/#access_token=***&view=public" in context
+ assert "/resume?token=***&view=public" in context
+ assert "client%5Fsecret=***&view=public" in context
+ assert "client-secret=***&view=public" in context
+ assert "Access-Token=***&view=public" in context
+ assert "api-key=***&view=public" in context
+ assert "client%2Dsecret=***&view=public" in context
+ assert "//user:***@x.test/path" in context
+
+
+def test_provider_context_is_bounded_before_plugin_engine():
+ manager = MagicMock()
+ manager.on_pre_compress.return_value = "HEAD-SENTINEL" + "x" * 8_000 + "TAIL-SENTINEL"
+ received = []
+ compressor = MagicMock()
+
+ def capture_compress(messages, current_tokens=None, memory_context="", **_kwargs):
+ received.append(memory_context)
+ return [messages[0], messages[-1]]
+
+ compressor.compress.side_effect = capture_compress
+ _configure_engine_state(compressor)
+ agent = _make_agent(manager, compressor)
+
+ agent._compress_context(_messages(), "sys", approx_tokens=100_000)
+
+ assert len(received) == 1
+ context = received[0]
+ assert len(context) <= 6_000
+ assert context.startswith("HEAD-SENTINEL")
+ assert context.endswith("TAIL-SENTINEL")
+ assert "[memory provider context truncated]" in context
+
+
+def test_internal_engine_type_error_propagates_after_one_call():
+ manager = MagicMock()
+ manager.on_pre_compress.return_value = "Checkpoint id: ctx-typeerror"
+ calls = []
+
+ class BrokenEngine:
+ def compress(
+ self,
+ messages,
+ current_tokens=None,
+ focus_topic=None,
+ force=False,
+ memory_context="",
+ ):
+ calls.append(memory_context)
+ raise TypeError("engine implementation bug")
+
+ engine = BrokenEngine()
+ _configure_engine_state(engine)
+ agent = _make_agent(manager, engine)
+
+ with pytest.raises(TypeError, match="engine implementation bug"):
+ agent._compress_context(_messages(), "sys", approx_tokens=100_000)
+
+ assert calls == ["Checkpoint id: ctx-typeerror"]
diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py
index c57485bb0d5e..071596e20465 100644
--- a/tests/run_agent/test_run_agent_codex_responses.py
+++ b/tests/run_agent/test_run_agent_codex_responses.py
@@ -2371,6 +2371,37 @@ def test_interim_commentary_is_not_marked_already_streamed_when_stream_callback_
}
+def test_interim_content_was_streamed_matches_prefix_not_exact(monkeypatch):
+ """_interim_content_was_streamed should return True when the streamed text
+ is a PREFIX of the final content (trailing delta added after stream, or
+ partial stream before verify nudge). Exact equality is too strict — it
+ fails safe to a benign duplicate bubble instead of settling the interim.
+ (#65919 review: prefix-based match like the TUI's finalTail dedup.)"""
+ agent = _build_agent(monkeypatch)
+
+ # Exact match still works
+ agent._current_streamed_assistant_text = "hello world"
+ assert agent._interim_content_was_streamed("hello world") is True
+
+ # Streamed is a prefix of the final (trailing delta) — should match
+ agent._current_streamed_assistant_text = "hello"
+ assert agent._interim_content_was_streamed("hello world") is True
+
+ # Streamed is empty — should not match
+ agent._current_streamed_assistant_text = ""
+ assert agent._interim_content_was_streamed("hello world") is False
+
+ # Final is empty — should not match
+ agent._current_streamed_assistant_text = "hello"
+ assert agent._interim_content_was_streamed("") is False
+
+ # Streamed is LONGER than final (reverse direction) — should NOT match.
+ # This is the unsafe direction: it could suppress a needed resend in the
+ # gateway path where already_streamed=True calls on_segment_break().
+ agent._current_streamed_assistant_text = "hello world extra"
+ assert agent._interim_content_was_streamed("hello") is False
+
+
def test_interim_commentary_preserves_assistant_content(monkeypatch):
"""Interim commentary must not silently mutate assistant text containing
literal markers — that's legitimate model output (docs,
diff --git a/tests/run_agent/test_verification_continuation_budget.py b/tests/run_agent/test_verification_continuation_budget.py
index d6f65407e7ab..a3fc5d6031fe 100644
--- a/tests/run_agent/test_verification_continuation_budget.py
+++ b/tests/run_agent/test_verification_continuation_budget.py
@@ -1,4 +1,4 @@
-"""End-to-end regression coverage for verification budget exhaustion (#61631)."""
+"""End-to-end regression coverage for verification budget exhaustion (#61631, #65919 §7)."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -51,11 +51,12 @@ def _assert_pending_response_survives(agent, result):
assert result["turn_exit_reason"] == "max_iterations_reached(1/1)"
assert result["completed"] is False
assert agent._handle_max_iterations.call_count == 0
+ # The nudge is stripped by _drop_verification_continuation_scaffolding,
+ # so the role sequence is [user, assistant] — the candidate is the
+ # tail and matches final_response so it is not duplicated. (#65919 §7)
assert [message["role"] for message in result["messages"]] == [
"user",
"assistant",
- "user",
- "assistant",
]
@@ -75,8 +76,8 @@ def test_verify_on_stop_preserves_composed_report_at_budget_limit(agent, monkeyp
result = agent.run_conversation("edit changed.py")
_assert_pending_response_survives(agent, result)
- assert result["messages"][1]["_verification_stop_synthetic"] is True
- assert result["messages"][2]["_verification_stop_synthetic"] is True
+ # The assistant response persists (it is real, unflagged content).
+ assert not result["messages"][1].get("_verification_stop_synthetic")
def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch):
@@ -100,8 +101,8 @@ def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch
result = agent.run_conversation("edit changed.py")
_assert_pending_response_survives(agent, result)
- assert result["messages"][1]["_pre_verify_synthetic"] is True
- assert result["messages"][2]["_pre_verify_synthetic"] is True
+ # The assistant response persists (it is real, unflagged content).
+ assert not result["messages"][1].get("_pre_verify_synthetic")
def test_intermediate_ack_uses_summary_instead_of_premature_text(agent, monkeypatch):
@@ -144,3 +145,177 @@ def test_later_verified_response_supersedes_pending_report(agent, monkeypatch):
assert result["turn_exit_reason"] == "text_response(finish_reason=stop)"
assert result["completed"] is True
agent._handle_max_iterations.assert_not_called()
+
+
+def test_multiple_verification_retries_publish_each_candidate_once(agent, monkeypatch):
+ """Multiple verification retries should publish each candidate once, in order."""
+ agent.max_iterations = 3
+ agent.iteration_budget.max_total = 3
+ answers = iter([
+ _response("candidate one"),
+ _response("candidate two"),
+ _response("candidate three"),
+ ])
+ agent._interruptible_api_call = lambda _kwargs: next(answers)
+ agent._handle_max_iterations = MagicMock(return_value="replacement summary")
+ monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1")
+
+ # Three nudges, then None (so the third candidate is the final response).
+ nudge_side_effects = ["verify it", "verify it", None]
+
+ emitted = []
+ agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)
+
+ with (
+ patch(
+ "agent.verification_stop.build_verify_on_stop_nudge",
+ side_effect=nudge_side_effects,
+ ),
+ patch("hermes_cli.plugins.invoke_hook", return_value=[]),
+ ):
+ result = agent.run_conversation("edit changed.py")
+
+ # Each candidate was emitted as an interim message, in order.
+ assert emitted == ["candidate one", "candidate two"]
+ # The final response is the last candidate.
+ assert result["final_response"] == "candidate three"
+ assert result["turn_exit_reason"] == "text_response(finish_reason=stop)"
+ assert result["completed"] is True
+ agent._handle_max_iterations.assert_not_called()
+
+
+def test_verification_false_finalizes_candidate_once(agent, monkeypatch):
+ """When verification returns false/exception, the candidate is finalized once."""
+ agent._interruptible_api_call = lambda _kwargs: _response("the answer")
+ agent._handle_max_iterations = MagicMock(return_value="replacement summary")
+ monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1")
+
+ emitted = []
+ agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)
+
+ with (
+ # build_verify_on_stop_nudge raises — simulates verification check failure
+ patch(
+ "agent.verification_stop.build_verify_on_stop_nudge",
+ side_effect=RuntimeError("verify check crashed"),
+ ),
+ patch("hermes_cli.plugins.invoke_hook", return_value=[]),
+ ):
+ result = agent.run_conversation("edit changed.py")
+
+ # No interim emission because verification did not run (exception path
+ # sets _verify_nudge = None, so the candidate becomes the final response
+ # without an interim emission).
+ assert result["final_response"] == "the answer"
+ assert result["completed"] is True
+ agent._handle_max_iterations.assert_not_called()
+
+
+def test_verify_on_stop_emits_interim_response_to_ui(agent, monkeypatch):
+ """The verify-on-stop path must emit the full response to the UI callback.
+
+ With no streaming set up in this test, _interim_content_was_streamed
+ returns False, so already_streamed is False — the callback reports
+ content the UI has not seen yet.
+ """
+ agent._interruptible_api_call = lambda _kwargs: _response("composed report")
+ agent._handle_max_iterations = MagicMock(return_value="replacement summary")
+ monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1")
+
+ callback_calls = []
+
+ def capture_callback(text, *, already_streamed=None):
+ callback_calls.append({"text": text, "already_streamed": already_streamed})
+
+ agent.interim_assistant_callback = capture_callback
+
+ with (
+ patch("agent.verification_stop.build_verify_on_stop_nudge", return_value="verify it"),
+ patch("hermes_cli.plugins.invoke_hook", return_value=[]),
+ ):
+ result = agent.run_conversation("edit changed.py")
+
+ # The callback was called with the full response text and already_streamed=False
+ assert len(callback_calls) == 1
+ assert callback_calls[0]["text"] == "composed report"
+ assert callback_calls[0]["already_streamed"] is False
+
+ # The candidate persists as the final response.
+ assert result["final_response"] == "composed report"
+
+
+def test_streamed_interim_then_different_summary_not_marked_previewed(agent, monkeypatch):
+ """Ordinary interim narration followed by a different non-streamed summary.
+
+ The model streams "I'll inspect the files now" as an intermediate ack.
+ _emit_interim_assistant_message is called for this ordinary narration,
+ which must NOT set _response_was_previewed. Then _handle_max_iterations
+ produces a different summary through the non-streaming Chat Completions
+ path. The final result must NOT be marked as previewed — the interim was
+ unrelated mid-turn commentary, not the final response — so the CLI renders
+ the summary instead of suppressing it. (#65919 review: response-loss blocker)
+ """
+ agent.valid_tool_names = ["web_search"]
+ agent._intent_ack_continuation = True
+ agent._looks_like_codex_intermediate_ack = MagicMock(return_value=True)
+ agent._interruptible_api_call = lambda _kwargs: _response("I'll inspect the files now")
+ agent._handle_max_iterations = MagicMock(return_value="Here is the summary of what I found.")
+ monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "0")
+
+ emitted = []
+ agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)
+
+ with (
+ patch("hermes_cli.plugins.has_hook", return_value=False),
+ patch("hermes_cli.plugins.invoke_hook", return_value=[]),
+ ):
+ result = agent.run_conversation("inspect /tmp/project")
+
+ # The final response is the different summary from _handle_max_iterations.
+ assert result["final_response"] == "Here is the summary of what I found."
+ # CRITICAL: response_previewed must be False — the interim narration was
+ # NOT the final response, so the CLI must render the summary.
+ assert result["response_previewed"] is False
+
+
+def test_streamed_verification_candidate_reused_marked_previewed(agent, monkeypatch):
+ """Verification candidate reused at budget exhaustion is marked previewed.
+
+ The model streams a verification candidate that is already streamed as
+ interim content. The continuation budget is exhausted, so the finalizer
+ reuses the pending verification candidate as the final response. The result
+ must be marked as previewed so the CLI/desktop settle it once instead of
+ duplicating. (#65919 review)
+ """
+ agent._interruptible_api_call = lambda _kwargs: _response("composed report")
+ agent._handle_max_iterations = MagicMock(return_value="replacement summary")
+ monkeypatch.setenv("HERMES_VERIFY_ON_STOP", "1")
+
+ agent._turn_file_mutation_paths = {"changed.py"}
+
+ callback_calls = []
+
+ def capture_callback(text, *, already_streamed=None):
+ callback_calls.append({"text": text, "already_streamed": already_streamed})
+
+ agent.interim_assistant_callback = capture_callback
+
+ # Simulate that the candidate text was already streamed. The streaming
+ # buffer is cleared after the response is processed, so mock the check
+ # directly — this is the condition the test validates: when the candidate
+ # was streamed, the previewed flag propagates to the finalizer.
+ with (
+ patch.object(agent, "_interim_content_was_streamed", return_value=True),
+ patch("agent.verification_stop.build_verify_on_stop_nudge", return_value="verify it"),
+ patch("hermes_cli.plugins.invoke_hook", return_value=[]),
+ ):
+ result = agent.run_conversation("edit changed.py")
+
+ # The candidate was already streamed, so the callback reports already_streamed=True.
+ assert len(callback_calls) == 1
+ assert callback_calls[0]["already_streamed"] is True
+ # The candidate is reused as the final response.
+ assert result["final_response"] == "composed report"
+ # CRITICAL: response_previewed must be True — the reused candidate was
+ # streamed as interim content, so the CLI/desktop settle it once.
+ assert result["response_previewed"] is True
diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py
index d69d6b3c6018..cd49079787ac 100644
--- a/tests/test_gateway_streaming_nested_config.py
+++ b/tests/test_gateway_streaming_nested_config.py
@@ -41,3 +41,126 @@ class TestStreamingConfigNested:
})
assert cfg.streaming.enabled is True
assert cfg.streaming.transport == "edit"
+
+
+class TestStreamingModeAlias:
+ """``streaming: {mode: ...}`` is an alias that also implies ``enabled``.
+
+ Regression for a live config footgun: ``streaming: {mode: auto}`` was
+ silently ignored (mode was never read), so streaming stayed disabled and
+ the whole reply buffered before the first Telegram send.
+ """
+
+ def test_mode_auto_enables_streaming(self):
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"mode": "auto"})
+ assert sc.enabled is True
+ assert sc.transport == "auto"
+
+ def test_mode_edit_enables_streaming(self):
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"mode": "edit"})
+ assert sc.enabled is True
+ assert sc.transport == "edit"
+
+ def test_mode_off_disables_streaming(self):
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"mode": "off"})
+ assert sc.enabled is False
+ assert sc.transport == "off"
+
+ def test_mode_with_extra_keys_still_enables(self):
+ """Real-world block: mode plus unrelated preloader_frames."""
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict(
+ {"mode": "auto", "preloader_frames": ["a", "b"]}
+ )
+ assert sc.enabled is True
+ assert sc.transport == "auto"
+
+ def test_explicit_enabled_overrides_mode(self):
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"mode": "auto", "enabled": False})
+ assert sc.enabled is False
+ # transport still resolves from mode
+ assert sc.transport == "auto"
+
+ def test_transport_selects_transport_but_mode_controls_enabled(self):
+ """``transport`` picks HOW to stream; ``mode`` is the enable alias.
+
+ With ``mode: off`` the master switch is off even though ``transport``
+ selects the draft path — ``enabled`` is inferred from ``mode`` only.
+ """
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"mode": "off", "transport": "draft"})
+ assert sc.transport == "draft"
+ assert sc.enabled is False
+
+ def test_empty_block_stays_disabled(self):
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({})
+ assert sc.enabled is False
+
+ def test_transport_only_does_not_enable(self):
+ """``streaming.enabled`` is the documented master switch, so a bare
+ ``transport`` must not flip streaming on by itself."""
+ from gateway.config import StreamingConfig
+
+ sc = StreamingConfig.from_dict({"transport": "edit"})
+ assert sc.enabled is False
+ # transport is still recorded so it takes effect once streaming is on.
+ assert sc.transport == "edit"
+
+
+class TestStreamingYamlBooleanQuirk:
+ """YAML 1.1 parses bare ``off``/``on`` as booleans; ``mode``/``transport``
+ must normalize those back to canonical string tokens.
+
+ Regression for the review on PR #62873: bare ``mode: off`` arrived as
+ Python ``False`` and stringified to ``"false"``, which is not ``"off"``,
+ so streaming was enabled instead of honoring the advertised disable.
+ """
+
+ def test_mode_bare_off_boolean_disables(self):
+ from gateway.config import StreamingConfig
+
+ # yaml.safe_load("off") -> False
+ sc = StreamingConfig.from_dict({"mode": False})
+ assert sc.enabled is False
+ assert sc.transport == "off"
+
+ def test_mode_bare_on_boolean_enables(self):
+ from gateway.config import StreamingConfig
+
+ # yaml.safe_load("on") -> True
+ sc = StreamingConfig.from_dict({"mode": True})
+ assert sc.enabled is True
+ assert sc.transport == "auto"
+
+ def test_transport_bare_off_boolean_normalizes(self):
+ from gateway.config import StreamingConfig
+
+ # transport alone never enables, but the token must still normalize.
+ sc = StreamingConfig.from_dict({"transport": False})
+ assert sc.enabled is False
+ assert sc.transport == "off"
+
+ def test_loader_normalizes_bare_yaml_off(self):
+ """End-to-end through load_gateway_config(): unquoted ``mode: off``
+ (a YAML boolean) must keep streaming disabled."""
+ cfg = _load_with_yaml_dict({"streaming": {"mode": False}})
+ assert cfg.streaming.enabled is False
+ assert cfg.streaming.transport == "off"
+
+ def test_loader_nested_mode_enables(self):
+ """Nested gateway.streaming.mode alias enables through the loader."""
+ cfg = _load_with_yaml_dict({"gateway": {"streaming": {"mode": "edit"}}})
+ assert cfg.streaming.enabled is True
+ assert cfg.streaming.transport == "edit"
diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py
index 414b0d69c605..dd6ecbc55182 100644
--- a/tests/test_hermes_state.py
+++ b/tests/test_hermes_state.py
@@ -1365,6 +1365,57 @@ class TestMessageStorage:
assert model_history == model_expected
assert display_history == display_expected
+ def test_get_ancestor_display_prefix_single_session_returns_empty(self, db):
+ """A session with no compression ancestors has an empty prefix."""
+ db.create_session("solo", "cli")
+ db.append_message("solo", role="user", content="hi")
+ db.append_message("solo", role="assistant", content="hello")
+
+ assert db.get_ancestor_display_prefix("solo") == []
+
+ def test_get_ancestor_display_prefix_returns_ancestor_only_messages(self, db):
+ """The prefix contains ONLY ancestor messages, not tip messages.
+
+ Previously the prefix was calculated as
+ display_history[:len(display) - len(raw)], which overcounts when
+ repair_message_sequence removes messages from the MIDDLE of the
+ tip history — the length difference includes both ancestor messages
+ AND repair-removed tip messages, but the slice captures the first N
+ display messages (tip messages when there are no ancestors),
+ causing duplication in _live_session_payload. (#65919)
+ """
+ db.create_session("root", "tui")
+ db.append_message("root", role="user", content="ancestor prompt")
+ db.append_message("root", role="assistant", content="ancestor reply")
+ db.create_session("child", "tui", parent_session_id="root")
+ db.append_message("child", role="user", content="tip prompt")
+ db.append_message("child", role="assistant", content="tip reply")
+ # A verification candidate that repair_message_sequence collapses
+ # (consecutive-assistant merge replaces it with the next assistant).
+ db.append_message(
+ "child",
+ role="assistant",
+ content="verification candidate",
+ finish_reason="verification_required",
+ )
+ db.append_message("child", role="assistant", content="post-verification reply")
+
+ prefix = db.get_ancestor_display_prefix("child")
+ # Only the ancestor messages, not any tip messages.
+ assert len(prefix) == 2
+ assert prefix[0]["role"] == "user"
+ assert prefix[0]["content"] == "ancestor prompt"
+ assert prefix[1]["role"] == "assistant"
+ assert prefix[1]["content"] == "ancestor reply"
+
+ # The old broken calculation would produce a non-empty prefix
+ # (because repair collapses the verification candidate, making
+ # len(display) > len(raw)), even though there are 2 ancestor
+ # messages — it would overcount.
+ raw, display = db.get_resume_conversations("child")
+ old_prefix_len = max(0, len(display) - len(raw))
+ assert len(prefix) <= old_prefix_len
+
def test_finish_reason_stored(self, db):
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="assistant", content="Done", finish_reason="stop")
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index 71d709f5dc6a..da815f9979c1 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -1418,6 +1418,9 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
captured.setdefault("history_calls", []).append((target, include_ancestors))
return (
@@ -1555,6 +1558,9 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "hello"}]
@@ -1621,6 +1627,9 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "hello"}]
@@ -1757,6 +1766,18 @@ def test_stored_session_runtime_overrides_skips_bare_billing_provider():
assert ov["model_override"]["provider"] == "custom:myendpoint"
+def test_stored_session_runtime_overrides_restores_explicit_normal_tier():
+ overrides = server._stored_session_runtime_overrides(
+ {
+ "model": "gpt-5.4",
+ "model_config": {"service_tier": "normal"},
+ }
+ )
+
+ assert "service_tier_override" in overrides
+ assert overrides["service_tier_override"] == ""
+
+
def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
updates = {}
@@ -1796,6 +1817,37 @@ def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
)
+def test_persist_live_session_runtime_preserves_explicit_normal_tier():
+ updates = {}
+
+ class FakeDB:
+ def get_session(self, _session_id):
+ return {"model_config": '{"service_tier":"priority"}'}
+
+ def update_session_meta(self, _session_id, model_config_json, model=None):
+ updates["config"] = json.loads(model_config_json)
+
+ agent = types.SimpleNamespace(
+ model="gpt-5.4",
+ provider="openai-codex",
+ base_url=None,
+ api_mode=None,
+ reasoning_config=None,
+ service_tier="",
+ _session_db=FakeDB(),
+ )
+
+ server._persist_live_session_runtime(
+ {
+ "agent": agent,
+ "session_key": "stored-session",
+ "create_service_tier_override": "",
+ }
+ )
+
+ assert updates["config"]["service_tier"] == "normal"
+
+
def test_status_callback_emits_kind_and_text():
with patch("tui_gateway.server._emit") as emit:
cb = server._agent_cbs("sid")["status_callback"]
@@ -4606,8 +4658,18 @@ def test_complete_slash_details_args():
assert any(item["text"] == "expanded" for item in resp_mode["result"]["items"])
+def test_complete_slash_reasoning_includes_current_efforts_and_global_scope():
+ resp = server.handle_request(
+ {"id": "1", "method": "complete.slash", "params": {"text": "/reasoning "}}
+ )
+
+ values = {item["text"] for item in resp["result"]["items"]}
+ assert {"max", "ultra", "--global"} <= values
+
+
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_hermes_home", tmp_path)
+ (tmp_path / "config.yaml").write_text("agent:\n reasoning_effort: medium\n", encoding="utf-8")
agent = types.SimpleNamespace(reasoning_config=None)
server._sessions["sid"] = _session(agent=agent)
@@ -4615,11 +4677,42 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat
{
"id": "1",
"method": "config.set",
- "params": {"session_id": "sid", "key": "reasoning", "value": "low"},
+ "params": {
+ "session_id": "sid",
+ "key": "reasoning",
+ "value": "low",
+ },
}
)
assert resp_effort["result"]["value"] == "low"
assert agent.reasoning_config == {"enabled": True, "effort": "low"}
+ assert server._sessions["sid"]["create_reasoning_override"] == {"enabled": True, "effort": "low"}
+ assert server._load_cfg()["agent"]["reasoning_effort"] == "medium"
+
+ resp_status = server.handle_request(
+ {
+ "id": "5",
+ "method": "config.get",
+ "params": {"session_id": "sid", "key": "reasoning"},
+ }
+ )
+ assert resp_status["result"]["value"] == "low"
+
+ resp_global_status = server.handle_request(
+ {"id": "6", "method": "config.get", "params": {"key": "reasoning"}}
+ )
+ assert resp_global_status["result"]["value"] == "medium"
+
+ del server._sessions["sid"]["create_reasoning_override"]
+ agent.reasoning_config = {"enabled": True, "effort": "high"}
+ resp_agent_status = server.handle_request(
+ {
+ "id": "7",
+ "method": "config.get",
+ "params": {"session_id": "sid", "key": "reasoning"},
+ }
+ )
+ assert resp_agent_status["result"]["value"] == "high"
resp_show = server.handle_request(
{
@@ -4671,6 +4764,36 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat
assert cfg_clamp["display"]["sections"]["thinking"] == "collapsed"
+def test_config_set_reasoning_global_scope_clears_session_override(tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "_hermes_home", tmp_path)
+ (tmp_path / "config.yaml").write_text("agent:\n reasoning_effort: medium\n", encoding="utf-8")
+ agent = types.SimpleNamespace(reasoning_config=None)
+ server._sessions["sid"] = _session(agent=agent)
+ server._sessions["sid"]["create_reasoning_override"] = {"enabled": True, "effort": "low"}
+
+ resp = server.handle_request(
+ {
+ "id": "1",
+ "method": "config.set",
+ "params": {
+ "session_id": "sid",
+ "key": "reasoning",
+ "value": "high",
+ "scope": "global",
+ },
+ }
+ )
+
+ assert resp["result"]["value"] == "high"
+ assert server._load_cfg()["agent"]["reasoning_effort"] == "high"
+ assert "create_reasoning_override" not in server._sessions["sid"]
+
+ status = server.handle_request(
+ {"id": "2", "method": "config.get", "params": {"session_id": "sid", "key": "reasoning"}}
+ )
+ assert status["result"]["value"] == "high"
+
+
def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_hermes_home", tmp_path)
agent = types.SimpleNamespace(verbose_logging=False)
@@ -5564,6 +5687,9 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False):
assert key == "session-key"
assert include_ancestors is True
@@ -10267,8 +10393,16 @@ def test_session_create_records_ui_model_as_session_override(monkeypatch):
assert resp["result"]["info"]["model"] == "claude-sonnet-4.6"
assert resp["result"]["info"]["provider"] == "anthropic"
+ # Explicit false is not the same as omission: it must suppress a Fast
+ # profile default for this session's first request.
+ normal = server._methods["session.create"](
+ "r2", {"cols": 80, "fast": False}
+ )
+ normal_sess = server._sessions[normal["result"]["session_id"]]
+ assert normal_sess["create_service_tier_override"] == ""
+
# No knobs → no overrides; the session builds from the profile default.
- plain = server._methods["session.create"]("r2", {"cols": 80})
+ plain = server._methods["session.create"]("r3", {"cols": 80})
plain_sess = server._sessions[plain["result"]["session_id"]]
assert plain_sess["model_override"] is None
assert plain_sess["create_reasoning_override"] is None
@@ -10277,7 +10411,10 @@ def test_session_create_records_ui_model_as_session_override(monkeypatch):
server._sessions.clear()
-def test_start_agent_build_passes_session_model_override(monkeypatch):
+@pytest.mark.parametrize("service_tier_override", ["priority", ""])
+def test_start_agent_build_passes_session_model_override(
+ monkeypatch, service_tier_override
+):
"""A model staged on the session (e.g. by session.create from the desktop
composer) must reach _make_agent so the first build runs on it directly —
no global config, no build-then-switch.
@@ -10317,7 +10454,7 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
"profile_home": None,
"model_override": override,
"create_reasoning_override": reasoning,
- "create_service_tier_override": "priority",
+ "create_service_tier_override": service_tier_override,
}
server._sessions[sid] = session
try:
@@ -10325,7 +10462,7 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
assert session["agent_ready"].wait(timeout=3), "agent build did not finish"
assert captured.get("model_override") == override
assert captured.get("reasoning_config_override") == reasoning
- assert captured.get("service_tier_override") == "priority"
+ assert captured.get("service_tier_override") == service_tier_override
assert session["agent"].model == "claude-sonnet-4.6"
finally:
server._sessions.clear()
@@ -10334,6 +10471,50 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
# ── billing/subscription state + error serialization ─────────────────
+def test_reset_session_agent_clears_session_overrides(monkeypatch):
+ """/new is a full conversation boundary: session-scoped /model, /reasoning,
+ and /fast overrides do NOT carry into the fresh agent — it re-derives
+ everything from config.yaml (#48055, #23131)."""
+ captured = {}
+ new_agent = types.SimpleNamespace(model="openai/gpt-5.4", service_tier="")
+ session = _session(
+ agent=types.SimpleNamespace(
+ model="openai/gpt-5.4",
+ reasoning_config={"enabled": True, "effort": "high"},
+ service_tier="",
+ ),
+ model_override={"model": "openai/gpt-5.4"},
+ create_reasoning_override={"enabled": True, "effort": "high"},
+ create_service_tier_override="",
+ )
+
+ def make_agent(*_args, **kwargs):
+ captured.update(kwargs)
+ return new_agent
+
+ monkeypatch.setattr(server, "_set_session_context", lambda _key: [])
+ monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
+ monkeypatch.setattr(server, "_make_agent", make_agent)
+ monkeypatch.setattr(server, "_config_model_target", lambda: ("", ""))
+ monkeypatch.setattr(server, "_load_show_reasoning", lambda: True)
+ monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
+ monkeypatch.setattr(server, "_session_info", lambda *_args: {})
+ monkeypatch.setattr(server, "_emit", lambda *_args: None)
+ monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args: None)
+
+ server._reset_session_agent("sid", session)
+
+ # No session overrides forwarded — fresh agent builds from config.
+ assert "model_override" not in captured
+ assert "reasoning_config_override" not in captured
+ assert "service_tier_override" not in captured
+ # And the session pins are gone so a later rebuild can't resurrect them.
+ assert "model_override" not in session
+ assert "create_reasoning_override" not in session
+ assert "create_service_tier_override" not in session
+ assert session["agent"] is new_agent
+
+
@pytest.mark.parametrize(
"card,expected",
[
diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py
index fcc4abcb1f10..0862b6722c84 100644
--- a/tests/tools/test_credential_files.py
+++ b/tests/tools/test_credential_files.py
@@ -530,3 +530,115 @@ class TestIterCacheFiles:
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
assert iter_cache_files() == []
+
+
+class TestMasterCredentialStoresAreNeverMountable:
+ """Containment is not enough — HERMES_HOME *is* where the keys live.
+
+ ``required_credential_files`` is skill-declared frontmatter, and skills are
+ installed from the hub. The traversal guard already stops
+ ``../../.ssh/id_rsa`` from escaping HERMES_HOME, but every master
+ credential store sits *inside* it: a one-line declaration would otherwise
+ bind-mount ``.env`` (every provider key) or ``auth.json`` (all provider
+ tokens and OAuth grants) read-only into the sandbox the skill's own code
+ runs in.
+
+ The bar is the canonical read deny-list: whatever the agent is forbidden to
+ ``read_file`` must not be mountable either, so the mount surface can't
+ grant what the read surface denies.
+ """
+
+ @staticmethod
+ def _home(tmp_path):
+ home = tmp_path / ".hermes"
+ home.mkdir()
+ (home / ".env").write_text("OPENAI_API_KEY=sk-proj-REAL\n")
+ (home / "auth.json").write_text('{"providers":{}}')
+ (home / ".anthropic_oauth.json").write_text('{"refresh_token":"rt"}')
+ (home / "webhook_subscriptions.json").write_text("{}")
+ (home / "cache").mkdir()
+ (home / "cache" / "bws_cache.json").write_text("{}")
+ (home / "mcp-tokens").mkdir()
+ (home / "mcp-tokens" / "srv.json").write_text('{"access_token":"t"}')
+ (home / "google_token.json").write_text("{}")
+ return home
+
+ @pytest.mark.parametrize(
+ "rel_path",
+ [
+ ".env",
+ "auth.json",
+ ".anthropic_oauth.json",
+ "webhook_subscriptions.json",
+ "cache/bws_cache.json",
+ "mcp-tokens/srv.json",
+ ],
+ )
+ def test_master_credential_store_is_refused(self, tmp_path, rel_path):
+ home = self._home(tmp_path)
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
+ assert register_credential_file(rel_path) is False, (
+ f"{rel_path} would be bind-mounted into the sandbox"
+ )
+ assert get_credential_file_mounts() == []
+
+ def test_per_service_token_still_mounts(self, tmp_path):
+ """The module's legitimate purpose must keep working."""
+ home = self._home(tmp_path)
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
+ assert register_credential_file("google_token.json") is True
+ mounts = get_credential_file_mounts()
+ assert [m["container_path"] for m in mounts] == [
+ "/root/.hermes/google_token.json"
+ ]
+
+ def test_refused_entry_does_not_block_the_rest_of_the_batch(self, tmp_path):
+ home = self._home(tmp_path)
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
+ missing = register_credential_files([".env", "google_token.json"])
+ mounts = get_credential_file_mounts()
+
+ paths = [m["container_path"] for m in mounts]
+ assert "/root/.hermes/google_token.json" in paths
+ assert "/root/.hermes/.env" not in paths
+ assert ".env" in missing, "a refused store is reported back to the skill"
+
+ def test_traversal_guard_still_applies(self, tmp_path):
+ """The pre-existing containment check is untouched."""
+ home = self._home(tmp_path)
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
+ assert register_credential_file("../../.ssh/id_rsa") is False
+ assert register_credential_file("/etc/passwd") is False
+
+ def test_missing_guard_fails_closed_with_error_log(self, tmp_path, caplog):
+ """If agent.file_safety can't be imported the mount is refused loudly.
+
+ The fail-closed path must be observable (#67665): a silent deny with
+ no diagnostic reproduces the trust gap the deny-list was added to fix.
+ """
+ import tools.credential_files as cf
+
+ home = self._home(tmp_path)
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
+ patch.object(cf, "get_read_block_error", None):
+ with caplog.at_level("ERROR", logger="tools.credential_files"):
+ assert cf.register_credential_file("google_token.json") is False
+ assert cf.get_credential_file_mounts() == []
+ assert any("deny-list cannot be consulted" in r.message for r in caplog.records)
+
+ def test_guard_exception_fails_closed_with_traceback(self, tmp_path, caplog):
+ """A raising guard refuses the mount and logs the stack trace."""
+ import tools.credential_files as cf
+
+ home = self._home(tmp_path)
+
+ def _boom(path):
+ raise RuntimeError("guard exploded")
+
+ with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
+ patch.object(cf, "get_read_block_error", _boom):
+ with caplog.at_level("ERROR", logger="tools.credential_files"):
+ assert cf.register_credential_file("google_token.json") is False
+ assert cf.get_credential_file_mounts() == []
+ rec = next(r for r in caplog.records if "read guard raised" in r.message)
+ assert rec.exc_info is not None, "traceback must be attached (logger.exception)"
diff --git a/tests/tools/test_delegation_live_log.py b/tests/tools/test_delegation_live_log.py
new file mode 100644
index 000000000000..d8e77d3e61c9
--- /dev/null
+++ b/tests/tools/test_delegation_live_log.py
@@ -0,0 +1,611 @@
+"""Tests for tools/delegation_live_log.py — live subagent transcripts.
+
+Covers:
+- writer event rendering + truncation + append/flush semantics
+- failure-swallowing when the target dir is unwritable
+- the tool_progress_callback observe() demux (assistant/tool events in order)
+- dispatch-time creation: paths pre-created with a header, manifest written
+- retention pruning of stale live dirs
+- delegate_task return-shape: live_transcripts in sync + background dispatch
+"""
+
+import json
+import os
+import threading
+import time
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from tools import delegation_live_log as dll
+from tools.delegation_live_log import (
+ LiveTranscriptWriter,
+ create_live_transcripts,
+ live_transcript_root,
+ prune_stale_live_dirs,
+ update_manifest_statuses,
+ wrap_progress_callback,
+)
+
+
+# ---------------------------------------------------------------------------
+# Writer unit tests
+# ---------------------------------------------------------------------------
+
+
+def test_writer_precreates_file_with_header():
+ w = LiveTranscriptWriter("deleg_test1", 0, "do the thing", context="some ctx")
+ assert w.path is not None and w.path.exists()
+ text = w.path.read_text(encoding="utf-8")
+ assert "Hermes subagent live transcript" in text
+ assert "delegation: deleg_test1" in text
+ assert "goal: do the thing" in text
+ assert "kickoff" in text
+ assert "some ctx" in text
+ # Lives under the hermes cache/delegation/live root, named task-.log
+ assert w.path.name == "task-0.log"
+ assert w.path.parent.name == "deleg_test1"
+ assert w.path.parent.parent == live_transcript_root()
+
+
+def test_writer_event_lines_append_in_order_and_flush_immediately():
+ w = LiveTranscriptWriter("deleg_order", 1, "goal")
+ w.assistant_text("I'll inspect the repo first.")
+ w.tool_start("terminal", "ls -la /tmp")
+ w.tool_result("terminal", result="file1\nfile2", duration=1.234, is_error=False)
+ w.thinking("hmm, next step")
+ # No close() needed: every event is flushed on write.
+ lines = w.path.read_text(encoding="utf-8").splitlines()
+ body = [ln for ln in lines if "|" in ln and not ln.startswith("=")]
+ joined = "\n".join(body)
+ assert "assistant" in joined and "I'll inspect the repo first." in joined
+ assert "-> terminal(ls -la /tmp)" in joined
+ assert "terminal ok 1.2s: file1 file2" in joined
+ assert "hmm, next step" in joined
+ # Ordering: assistant before tool before result before think
+ idx = {k: joined.index(k) for k in ("I'll inspect", "-> terminal", "terminal ok", "hmm,")}
+ assert idx["I'll inspect"] < idx["-> terminal"] < idx["terminal ok"] < idx["hmm,"]
+
+
+def test_writer_truncates_long_text_with_elision_note():
+ w = LiveTranscriptWriter("deleg_trunc", 0, "g")
+ w.assistant_text("x" * 5000)
+ w.tool_result("web_search", result="y" * 5000)
+ text = w.path.read_text(encoding="utf-8")
+ assert "…(+" in text # elision marker present
+ # No line carries the full 5000 chars
+ assert all(len(ln) < 1200 for ln in text.splitlines())
+
+
+def test_writer_collapses_newlines_to_single_line_events():
+ w = LiveTranscriptWriter("deleg_nl", 0, "g")
+ before = len(w.path.read_text(encoding="utf-8").splitlines())
+ w.assistant_text("line1\nline2\n\nline3")
+ after = w.path.read_text(encoding="utf-8").splitlines()
+ assert len(after) == before + 1
+ assert "line1 line2 line3" in after[-1]
+
+
+def test_writer_swallows_failures_when_dir_unwritable(tmp_path):
+ # Point the writer at a root that is actually a FILE — mkdir will fail.
+ bogus_root = tmp_path / "not-a-dir"
+ bogus_root.write_text("occupied")
+ w = LiveTranscriptWriter("deleg_fail", 0, "g", root=bogus_root)
+ assert w.path is None
+ # All writes must be silent no-ops.
+ w.assistant_text("hello")
+ w.tool_start("terminal", "ls")
+ w.marker("done")
+ w.observe("tool.completed", "terminal", result="x")
+ w.finalize({"status": "completed"})
+
+
+def test_writer_disables_itself_after_write_failure():
+ w = LiveTranscriptWriter("deleg_disable", 0, "g")
+ # Delete the parent dir out from under it and make writing impossible by
+ # replacing the path with a directory.
+ p = w.path
+ p.unlink()
+ p.mkdir()
+ w.assistant_text("should not raise")
+ assert w._ok is False
+ w.assistant_text("still silent") # no raise on subsequent calls
+
+
+def test_stream_deltas_buffer_and_flush_as_one_line():
+ w = LiveTranscriptWriter("deleg_stream", 0, "g")
+ w.add_stream_delta("Hello ")
+ w.add_stream_delta("world, ")
+ w.add_stream_delta("streaming.")
+ # Not yet flushed
+ assert "Hello world" not in w.path.read_text(encoding="utf-8")
+ w.flush_stream()
+ text = w.path.read_text(encoding="utf-8")
+ assert "Hello world, streaming." in text
+ # tool_start also flushes pending stream text first
+ w.add_stream_delta("more text")
+ w.tool_start("read_file", "foo.py")
+ text = w.path.read_text(encoding="utf-8")
+ assert text.index("more text") < text.index("-> read_file")
+
+
+# ---------------------------------------------------------------------------
+# observe() demux — the tool_progress_callback seam
+# ---------------------------------------------------------------------------
+
+
+def test_observe_maps_child_callback_events_to_lines():
+ w = LiveTranscriptWriter("deleg_observe", 0, "g")
+ w.observe("subagent.start", preview="kick off the goal")
+ w.observe("_thinking", "first line of thinking")
+ w.observe("reasoning.available", "_thinking", "deep reasoning text", None)
+ w.observe("tool.started", "terminal", "ls /tmp", {"command": "ls /tmp"})
+ w.observe("tool.completed", "terminal", None, None,
+ duration=0.5, is_error=False, result="ok output")
+ w.observe("subagent.text", preview="final reply ")
+ w.observe("subagent.text", preview="streamed in parts")
+ w.observe("subagent.complete", preview="short", status="completed",
+ duration_seconds=3.2, summary="did the thing")
+ text = w.path.read_text(encoding="utf-8")
+ assert "kick off the goal" in text
+ assert "first line of thinking" in text
+ assert "deep reasoning text" in text
+ assert "-> terminal(ls /tmp)" in text
+ assert "terminal ok 0.5s: ok output" in text
+ assert "final reply streamed in parts" in text
+ assert "status=completed" in text
+ assert "did the thing" in text
+
+
+def test_observe_marks_tool_errors():
+ w = LiveTranscriptWriter("deleg_err", 0, "g")
+ w.observe("tool.completed", "web_search", None, None,
+ is_error=True, result="Error: boom")
+ assert "web_search ERROR" in w.path.read_text(encoding="utf-8")
+
+
+def test_finalize_records_budget_exhaustion_and_errors():
+ w = LiveTranscriptWriter("deleg_final", 0, "g")
+ w.finalize({"status": "failed", "exit_reason": "max_iterations",
+ "error": "Subagent did not produce a response."})
+ text = w.path.read_text(encoding="utf-8")
+ assert "end status=failed" in text
+ assert "exit_reason=max_iterations" in text
+ assert "iteration budget exhausted" in text
+ assert "did not produce a response" in text
+
+
+def test_wrap_progress_callback_tees_and_preserves_inner():
+ w = LiveTranscriptWriter("deleg_wrap", 0, "g")
+ seen = []
+
+ def inner(event_type, tool_name=None, preview=None, args=None, **kw):
+ seen.append((event_type, tool_name))
+
+ inner_flushed = []
+ inner._flush = lambda: inner_flushed.append(True)
+
+ cb = wrap_progress_callback(inner, w)
+ cb("tool.started", "terminal", "echo hi", None)
+ cb("_thinking", "pondering")
+ assert seen == [("tool.started", "terminal"), ("_thinking", "pondering")]
+ text = w.path.read_text(encoding="utf-8")
+ assert "-> terminal(echo hi)" in text and "pondering" in text
+ # _flush contract preserved
+ cb._flush()
+ assert inner_flushed == [True]
+
+
+def test_wrap_progress_callback_with_no_inner_still_records():
+ w = LiveTranscriptWriter("deleg_noinner", 0, "g")
+ cb = wrap_progress_callback(None, w)
+ cb("tool.started", "read_file", "a.py", None)
+ cb._flush() # must not raise
+ assert "-> read_file(a.py)" in w.path.read_text(encoding="utf-8")
+
+
+def test_wrap_progress_callback_writer_failure_does_not_block_inner():
+ w = LiveTranscriptWriter("deleg_wfail", 0, "g")
+ w.observe = MagicMock(side_effect=RuntimeError("disk on fire"))
+ seen = []
+ cb = wrap_progress_callback(lambda *a, **k: seen.append(a), w)
+ cb("tool.started", "terminal", "x", None) # must not raise
+ assert len(seen) == 1
+
+
+# ---------------------------------------------------------------------------
+# Dispatch-time creation + manifest + retention
+# ---------------------------------------------------------------------------
+
+
+def test_create_live_transcripts_precreates_paths_and_manifest():
+ tasks = [{"goal": "task A"}, {"goal": "task B", "context": "ctx B"}]
+ deleg_id, writers, paths = create_live_transcripts(tasks, context="shared ctx")
+ assert deleg_id and deleg_id.startswith("deleg_")
+ assert len(writers) == 2 and all(w is not None for w in writers)
+ assert len(paths) == 2
+ for i, p in enumerate(paths):
+ assert os.path.isabs(p)
+ assert p.endswith(f"task-{i}.log")
+ assert Path(p).exists() # tail -f works immediately
+ manifest = json.loads(
+ (live_transcript_root() / deleg_id / "manifest.json").read_text()
+ )
+ assert manifest["task_count"] == 2
+ assert manifest["tasks"][0]["goal"] == "task A"
+ assert manifest["tasks"][0]["status"] == "running"
+ assert manifest["tasks"][1]["log"] == paths[1]
+ # Per-task context beats shared context in the kickoff line.
+ assert "ctx B" in Path(paths[1]).read_text(encoding="utf-8")
+
+
+def test_update_manifest_statuses():
+ tasks = [{"goal": "a"}, {"goal": "b"}]
+ deleg_id, _writers, _paths = create_live_transcripts(tasks)
+ update_manifest_statuses(deleg_id, [
+ {"task_index": 0, "status": "completed", "exit_reason": "completed"},
+ {"task_index": 1, "status": "error"},
+ ])
+ manifest = json.loads(
+ (live_transcript_root() / deleg_id / "manifest.json").read_text()
+ )
+ assert manifest["tasks"][0]["status"] == "completed"
+ assert manifest["tasks"][1]["status"] == "error"
+ assert "completed" in manifest
+
+
+def test_update_manifest_statuses_none_id_is_noop():
+ update_manifest_statuses(None, [{"task_index": 0, "status": "completed"}])
+
+
+def test_prune_stale_live_dirs():
+ root = live_transcript_root()
+ old_dir = root / "deleg_old00001"
+ new_dir = root / "deleg_new00001"
+ old_dir.mkdir(parents=True)
+ new_dir.mkdir(parents=True)
+ (old_dir / "task-0.log").write_text("old")
+ (new_dir / "task-0.log").write_text("new")
+ stale = time.time() - 8 * 86400
+ os.utime(old_dir, (stale, stale))
+ removed = prune_stale_live_dirs(max_age_days=7)
+ assert removed == 1
+ assert not old_dir.exists()
+ assert new_dir.exists()
+
+
+def test_create_live_transcripts_survives_root_failure(monkeypatch):
+ monkeypatch.setattr(
+ dll, "live_transcript_root",
+ lambda: (_ for _ in ()).throw(RuntimeError("no home")),
+ )
+ deleg_id, writers, paths = create_live_transcripts([{"goal": "g"}])
+ assert deleg_id is None
+ assert writers == [None]
+ assert paths == []
+
+
+# ---------------------------------------------------------------------------
+# delegate_task return-shape integration
+# ---------------------------------------------------------------------------
+
+
+def _make_parent():
+ parent = MagicMock()
+ parent._delegate_depth = 0
+ parent.session_id = "sess-live"
+ parent._interrupt_requested = False
+ parent._active_children = []
+ parent._active_children_lock = None
+ return parent
+
+
+_CREDS = {
+ "model": "m", "provider": None, "base_url": None, "api_key": None,
+ "api_mode": None, "command": None, "args": None,
+}
+
+
+def _fake_run(task_index, goal, child=None, parent_agent=None, **kw):
+ return {
+ "task_index": task_index, "status": "completed",
+ "summary": f"done: {goal}", "api_calls": 1,
+ "duration_seconds": 0.1, "model": "m", "exit_reason": "completed",
+ }
+
+
+def test_delegate_task_sync_result_includes_live_transcripts(monkeypatch):
+ import tools.delegate_tool as dt
+
+ parent = _make_parent()
+ fake_child = MagicMock()
+ fake_child._delegate_role = "leaf"
+ fake_child.tool_progress_callback = None
+ monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child)
+ monkeypatch.setattr(dt, "_run_single_child", _fake_run)
+ monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS)
+
+ out = json.loads(dt.delegate_task(goal="sync goal", parent_agent=parent))
+ assert "live_transcripts" in out
+ assert len(out["live_transcripts"]) == 1
+ p = Path(out["live_transcripts"][0])
+ assert p.exists()
+ assert "sync goal" in p.read_text(encoding="utf-8")
+ # Per-task entries carry their own path + a terminal marker was written.
+ assert out["results"][0]["live_transcript"] == str(p)
+ assert "end status=completed" in p.read_text(encoding="utf-8")
+
+
+def test_delegate_task_background_dispatch_includes_live_transcripts(monkeypatch):
+ import tools.delegate_tool as dt
+ from tools import async_delegation as ad
+ from tools.process_registry import process_registry
+
+ parent = _make_parent()
+ fake_child = MagicMock()
+ fake_child._delegate_role = "leaf"
+ fake_child._subagent_id = "s1"
+ fake_child.tool_progress_callback = None
+
+ gate = threading.Event()
+
+ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):
+ gate.wait(timeout=60)
+ return _fake_run(task_index, goal)
+
+ monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child)
+ monkeypatch.setattr(dt, "_run_single_child", slow_child)
+ monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS)
+
+ out = json.loads(dt.delegate_task(
+ goal="bg goal", background=True, parent_agent=parent,
+ ))
+ try:
+ assert out["status"] == "dispatched"
+ assert "live_transcripts" in out
+ assert len(out["live_transcripts"]) == 1
+ live = Path(out["live_transcripts"][0])
+ # Pre-created at dispatch time — tail -f attaches immediately,
+ # while the child is still running behind the gate.
+ assert live.exists()
+ assert "bg goal" in live.read_text(encoding="utf-8")
+ assert "live_transcripts_hint" in out
+ # The dir name matches the returned delegation handle.
+ assert live.parent.name == out["delegation_id"]
+ finally:
+ gate.set()
+ # Drain the completion so it can't leak into other tests.
+ deadline = time.time() + 30
+ evt = None
+ while time.time() < deadline:
+ try:
+ evt = process_registry.completion_queue.get(timeout=0.5)
+ break
+ except Exception:
+ continue
+ ad._reset_for_tests()
+
+ assert evt is not None
+ # The completion event carries the same paths for the consolidated block.
+ assert evt.get("live_transcripts") == out["live_transcripts"]
+ assert evt["results"][0]["live_transcript"] == out["live_transcripts"][0]
+
+
+def test_batch_dispatch_creates_one_log_per_task(monkeypatch):
+ import tools.delegate_tool as dt
+
+ parent = _make_parent()
+
+ def make_child(**kw):
+ c = MagicMock()
+ c._delegate_role = "leaf"
+ c.tool_progress_callback = None
+ return c
+
+ monkeypatch.setattr(dt, "_build_child_agent", make_child)
+ monkeypatch.setattr(dt, "_run_single_child", _fake_run)
+ monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS)
+
+ out = json.loads(dt.delegate_task(
+ tasks=[{"goal": "alpha"}, {"goal": "beta"}], parent_agent=parent,
+ ))
+ assert len(out["live_transcripts"]) == 2
+ names = [Path(p).name for p in out["live_transcripts"]]
+ assert names == ["task-0.log", "task-1.log"]
+ # Both under the same delegation dir
+ parents = {Path(p).parent for p in out["live_transcripts"]}
+ assert len(parents) == 1
+ for p, goal in zip(out["live_transcripts"], ("alpha", "beta")):
+ assert goal in Path(p).read_text(encoding="utf-8")
+
+
+def test_child_progress_events_land_in_live_log(monkeypatch):
+ """Events fired through the child's (wrapped) tool_progress_callback land
+ in the transcript file in order — the seam the real agent loop drives."""
+ import tools.delegate_tool as dt
+
+ parent = _make_parent()
+ built = []
+
+ def make_child(**kw):
+ c = MagicMock()
+ c._delegate_role = "leaf"
+ c.tool_progress_callback = None
+ built.append(c)
+ return c
+
+ def run_child(task_index, goal, child=None, parent_agent=None, **kw):
+ # Simulate what agent/tool_executor.py + conversation_loop.py emit.
+ cb = child.tool_progress_callback
+ cb("_thinking", "planning the work")
+ cb("tool.started", "terminal", "echo hi", {"command": "echo hi"})
+ cb("tool.completed", "terminal", None, None,
+ duration=0.2, is_error=False, result="hi")
+ return _fake_run(task_index, goal)
+
+ monkeypatch.setattr(dt, "_build_child_agent", make_child)
+ monkeypatch.setattr(dt, "_run_single_child", run_child)
+ monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS)
+
+ out = json.loads(dt.delegate_task(goal="observable goal", parent_agent=parent))
+ text = Path(out["live_transcripts"][0]).read_text(encoding="utf-8")
+ assert "planning the work" in text
+ assert "-> terminal(echo hi)" in text
+ assert "terminal ok 0.2s: hi" in text
+ assert text.index("planning") < text.index("-> terminal") < text.index("terminal ok")
+
+
+def test_delegate_task_proceeds_when_transcripts_unavailable(monkeypatch):
+ """Live-log failure must never break delegation itself."""
+ import tools.delegate_tool as dt
+ from tools import delegation_live_log as _dll
+
+ parent = _make_parent()
+ fake_child = MagicMock()
+ fake_child._delegate_role = "leaf"
+ fake_child.tool_progress_callback = None
+ monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child)
+ monkeypatch.setattr(dt, "_run_single_child", _fake_run)
+ monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: _CREDS)
+ monkeypatch.setattr(
+ _dll, "live_transcript_root",
+ lambda: (_ for _ in ()).throw(RuntimeError("nope")),
+ )
+
+ out = json.loads(dt.delegate_task(goal="resilient", parent_agent=parent))
+ assert out["results"][0]["status"] == "completed"
+ assert "live_transcripts" not in out
+
+
+if __name__ == "__main__":
+ import sys
+ sys.exit(pytest.main([__file__, "-v"]))
+
+
+# ---------------------------------------------------------------------------
+# Credential redaction
+# ---------------------------------------------------------------------------
+#
+# These transcripts land under ``cache/delegation``, which delegate_tool mounts
+# READ-ONLY into remote terminal backends — so a line written here is readable
+# from inside the sandbox. The rendered events are exactly the secret-bearing
+# surfaces (tool args, tool results, streamed assistant text), and every other
+# sink for that data already routes through the canonical redactor.
+
+_BEARER = "sk-ant-api03-" + "R" * 24
+_ENV_KEY = "sk-proj-" + "L" * 24
+_AWS = "wJalrXUtnFEMIK7MDENG" + "bPxRfiCY"
+
+
+def test_tool_args_are_redacted_before_hitting_disk():
+ w = LiveTranscriptWriter("deleg_redact_args", 0, "g")
+ w.observe(
+ "tool.started",
+ "terminal",
+ f'curl -H "Authorization: Bearer {_BEARER}" https://api.internal',
+ None,
+ )
+ body = w.path.read_text(encoding="utf-8")
+ assert _BEARER not in body
+ assert "terminal" in body, "redaction must not gut the operational detail"
+
+
+def test_tool_results_are_redacted_before_hitting_disk():
+ w = LiveTranscriptWriter("deleg_redact_result", 0, "g")
+ w.observe(
+ "tool.completed",
+ "terminal",
+ None,
+ None,
+ result=f"OPENAI_API_KEY={_ENV_KEY}\nAWS_SECRET_ACCESS_KEY={_AWS}",
+ duration=0.4,
+ )
+ body = w.path.read_text(encoding="utf-8")
+ assert _ENV_KEY not in body
+ assert _AWS not in body
+ assert "OPENAI_API_KEY" in body, "key NAMES stay — only the values are masked"
+
+
+def test_streamed_assistant_text_is_redacted():
+ w = LiveTranscriptWriter("deleg_redact_stream", 0, "g")
+ w.observe("subagent.text", None, f"the key is {_ENV_KEY}")
+ w.flush_stream()
+ assert _ENV_KEY not in w.path.read_text(encoding="utf-8")
+
+
+def test_goal_header_is_redacted():
+ """The header bypasses event(); a pasted key in the goal must not survive."""
+ w = LiveTranscriptWriter("deleg_redact_goal", 0, f"deploy using {_BEARER}")
+ body = w.path.read_text(encoding="utf-8")
+ assert _BEARER not in body
+ assert "deploy using" in body
+
+
+def test_manifest_goal_is_redacted():
+ """manifest.json shares the mounted dir with the .log files.
+
+ Redacting the log header while ``_write_manifest`` serialises the same goal
+ verbatim would leave the credential exposed one file over — both sinks in
+ ``cache/delegation/live//`` are readable from inside a sandbox.
+ """
+ delegation_id, _writers, _paths = create_live_transcripts(
+ [{"goal": f"deploy using {_BEARER}"}]
+ )
+
+ manifest = json.loads(
+ (live_transcript_root() / delegation_id / "manifest.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ goal = manifest["tasks"][0]["goal"]
+
+ assert _BEARER not in goal
+ assert "deploy using" in goal, "redaction must not blank the goal entirely"
+
+
+def test_no_file_in_the_dispatch_directory_carries_the_raw_key():
+ """Whole-directory sweep: every artefact dispatch writes is covered."""
+ delegation_id, _writers, _paths = create_live_transcripts(
+ [{"goal": f"deploy using {_BEARER}"}, {"goal": "second task"}]
+ )
+
+ directory = live_transcript_root() / delegation_id
+ written = sorted(p.name for p in directory.iterdir())
+
+ assert "manifest.json" in written
+ assert any(name.endswith(".log") for name in written)
+ for path in directory.iterdir():
+ assert _BEARER not in path.read_text(encoding="utf-8"), (
+ f"{path.name} leaked the credential"
+ )
+
+
+def test_thinking_text_is_redacted():
+ w = LiveTranscriptWriter("deleg_redact_think", 0, "g")
+ w.observe("_thinking", f"I should use {_ENV_KEY} here")
+ assert _ENV_KEY not in w.path.read_text(encoding="utf-8")
+
+
+def test_redaction_covers_every_helper_via_the_event_chokepoint():
+ """Any helper that reaches disk goes through event(), so all are covered."""
+ w = LiveTranscriptWriter("deleg_redact_all", 0, "g")
+ w.assistant_text(f"a {_ENV_KEY}")
+ w.thinking(f"b {_ENV_KEY}")
+ w.tool_start("terminal", f"c {_ENV_KEY}")
+ w.tool_result("terminal", result=f"d {_ENV_KEY}")
+ w.marker(f"e {_ENV_KEY}")
+ w.finalize({"status": "error", "error": f"f {_ENV_KEY}"})
+ body = w.path.read_text(encoding="utf-8")
+ assert _ENV_KEY not in body, "a write path escaped the redactor"
+
+
+def test_benign_transcript_content_is_untouched():
+ """Redaction must not mangle ordinary transcript text."""
+ w = LiveTranscriptWriter("deleg_redact_benign", 0, "refactor the parser")
+ w.observe("tool.started", "read_file", "src/parser.py", None)
+ w.observe("tool.completed", "read_file", None, None, result="def parse(x): ...", duration=1.5)
+ body = w.path.read_text(encoding="utf-8")
+ assert "src/parser.py" in body
+ assert "def parse(x)" in body
+ assert "refactor the parser" in body
diff --git a/tests/tools/test_env_probe.py b/tests/tools/test_env_probe.py
index a8ae89d84021..d20f3c234c19 100644
--- a/tests/tools/test_env_probe.py
+++ b/tests/tools/test_env_probe.py
@@ -151,7 +151,182 @@ class TestRobustness:
def boom(*a, **kw):
raise OSError("simulated")
monkeypatch.setattr(env_probe.subprocess, "run", boom)
+ monkeypatch.setattr(env_probe.subprocess, "Popen", boom)
# Should not raise, should just return ""
result = env_probe.get_environment_probe_line()
# Whatever the result is, it must be a string
assert isinstance(result, str)
+
+
+class TestStuckProbeNeverBlocksCallers:
+ """Regression for #67964: on Windows an orphaned pip descendant kept
+ the probe's capture pipes open, wedging the warm thread inside
+ subprocess._communicate while it held the module lock — every new
+ session's prompt build then blocked forever. Callers must fail open
+ within a bounded time no matter what the probe subprocesses do."""
+
+ def test_hung_probe_fails_open_for_concurrent_callers(self, monkeypatch):
+ """Concurrent get_environment_probe_line() callers return "" within
+ a bounded wall-clock time while the probe worker stays stuck."""
+ import threading as _threading
+ import time
+
+ release = _threading.Event()
+
+ def stuck_probe():
+ # Simulate the wedged pipe read: blocks until released.
+ release.wait(timeout=30)
+ return "Python toolchain: late-result."
+
+ monkeypatch.setattr(env_probe, "_build_probe_line", stuck_probe)
+ # Keep the test fast — the bound just has to exist, not be 10s.
+ monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.5)
+
+ env_probe.warm_environment_probe_async()
+
+ results: list[str] = []
+ errors: list[BaseException] = []
+
+ def caller():
+ try:
+ results.append(env_probe.get_environment_probe_line())
+ except BaseException as exc: # noqa: BLE001
+ errors.append(exc)
+
+ threads = [_threading.Thread(target=caller, daemon=True) for _ in range(4)]
+ start = time.monotonic()
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=10)
+ elapsed = time.monotonic() - start
+
+ try:
+ assert not errors
+ assert all(not t.is_alive() for t in threads), "caller blocked on stuck probe"
+ # All callers failed open with the empty line.
+ assert results == ["", "", "", ""]
+ # Bounded: nowhere near the 30s the probe is stuck for.
+ assert elapsed < 8
+ finally:
+ release.set()
+
+ def test_late_probe_result_published_after_recovery(self, monkeypatch):
+ """If the stuck worker eventually finishes, later callers get the
+ line — recovery without restart, matching the incident (killing
+ the orphan un-wedged everything)."""
+ import threading as _threading
+
+ release = _threading.Event()
+
+ def slow_probe():
+ release.wait(timeout=30)
+ return "Python toolchain: recovered."
+
+ monkeypatch.setattr(env_probe, "_build_probe_line", slow_probe)
+ monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.2)
+
+ # First caller times out and fails open.
+ assert env_probe.get_environment_probe_line() == ""
+
+ # Worker un-wedges (the operator killed the orphan).
+ release.set()
+ assert env_probe._PROBE_DONE.wait(timeout=10)
+
+ # Later callers see the published line.
+ assert env_probe.get_environment_probe_line() == "Python toolchain: recovered."
+
+ def test_repeat_callers_do_not_pay_full_wait_after_first_timeout(self, monkeypatch):
+ """After one caller burns the full wait, subsequent callers only
+ peek — a permanently stuck probe costs the timeout once, not
+ per-session."""
+ import threading as _threading
+ import time
+
+ release = _threading.Event()
+
+ def stuck_probe():
+ release.wait(timeout=30)
+ return ""
+
+ monkeypatch.setattr(env_probe, "_build_probe_line", stuck_probe)
+ monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.5)
+
+ try:
+ assert env_probe.get_environment_probe_line() == "" # pays 0.5s
+
+ # Crank the timeout way up: if the peek short-circuit is broken,
+ # the next call blocks ~30s; if it works, it returns in ~0.05s.
+ monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 30.0)
+ start = time.monotonic()
+ assert env_probe.get_environment_probe_line() == ""
+ assert time.monotonic() - start < 5 # peek, not a full wait
+ finally:
+ release.set()
+
+
+class TestRunTimeoutIsBounded:
+ """_run() itself must return within a bounded time even when the child
+ spawns a descendant that inherits the capture pipes and outlives it —
+ the exact Windows pip.exe launcher shape from #67964, reproduced
+ cross-platform with a shell child."""
+
+ def test_run_returns_promptly_despite_pipe_holding_descendant(self):
+ import time
+
+ # Child exits quickly; grandchild inherits stdout/stderr and sleeps
+ # far beyond the timeout, keeping the pipe write-ends open.
+ script = (
+ "import subprocess, sys, time\n"
+ "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(20)'])\n"
+ "time.sleep(20)\n"
+ )
+ start = time.monotonic()
+ rc, out, err = env_probe._run([sys.executable, "-c", script], timeout=1.0)
+ elapsed = time.monotonic() - start
+
+ assert rc == -1
+ assert err == "timeout"
+ # stdlib subprocess.run on Windows would hang here for the full 20s
+ # (unbounded post-kill communicate). Our bound: timeout + reap slack.
+ assert elapsed < 6
+
+
+class TestRunBoundedByTimeout:
+ """``_run`` must return as soon as the *direct* child exits, even when a
+ descendant inherited the captured stdout/stderr handles and outlives it.
+
+ This is the deadlock from #67964: on native Windows a ``pip.exe`` launcher
+ can leave a grandchild holding the captured pipe open, and ``capture_output``
+ reader threads then block far past the timeout while ``_CACHE_LOCK`` is held,
+ wedging every new session. Capturing through temp files removes the reader
+ threads, so a lingering grandchild can't block the parent's ``wait()``.
+
+ Cross-platform repro: the direct child prints ``ok`` and exits immediately
+ after spawning a long-sleeping grandchild that inherits its stdout. With the
+ old pipe-based capture, ``_run`` blocks until the grandchild exits (or hits
+ the 3s timeout and returns ``-1``); with temp-file capture it returns the
+ child's real output within a few milliseconds.
+ """
+
+ def test_returns_before_inheriting_grandchild_exits(self):
+ import time
+
+ grandchild_sleep = 20 # far longer than _run's timeout
+ # Direct child: emit "ok", spawn a detached grandchild that inherits
+ # this process's stdout (no stdout= redirect), then exit right away.
+ child_code = (
+ "import subprocess, sys; "
+ "subprocess.Popen([sys.executable, '-c', "
+ f"'import time; time.sleep({grandchild_sleep})']); "
+ "sys.stdout.write('ok'); sys.stdout.flush()"
+ )
+
+ start = time.monotonic()
+ rc, out, err = env_probe._run([sys.executable, "-c", child_code], timeout=3.0)
+ elapsed = time.monotonic() - start
+
+ # Must not wait on the grandchild, and must not have hit the timeout.
+ assert elapsed < 3.0, f"_run blocked on grandchild for {elapsed:.1f}s"
+ assert rc == 0, f"expected clean exit, got rc={rc} err={err!r}"
+ assert out == "ok"
diff --git a/tests/tools/test_file_sync.py b/tests/tools/test_file_sync.py
index 72ef2220cd12..ce49b4364794 100644
--- a/tests/tools/test_file_sync.py
+++ b/tests/tools/test_file_sync.py
@@ -225,6 +225,42 @@ class TestRateLimiting:
mgr.sync()
assert upload.call_count == 1
+ def test_failed_sync_does_not_suppress_next_retry(self, tmp_files, monkeypatch):
+ """A failed sync must not advance the rate-limit clock.
+
+ Regression: the failure path used to set ``_last_sync_time`` on
+ rollback, so the next non-forced ``sync()`` within ``sync_interval``
+ hit the rate-limit guard and returned early — silently suppressing the
+ retry the rollback had just prepared and leaving the remote stale.
+ """
+ from tools.environments import file_sync
+
+ clock = {"t": 1000.0}
+ monkeypatch.setattr(file_sync, "_monotonic", lambda: clock["t"])
+
+ upload = MagicMock(side_effect=RuntimeError("transport down"))
+ mgr = FileSyncManager(
+ get_files_fn=_make_get_files(tmp_files),
+ upload_fn=upload,
+ delete_fn=MagicMock(),
+ sync_interval=10.0,
+ )
+
+ # First sync fails (forced bypasses the guard); state rolls back.
+ mgr.sync(force=True)
+ assert upload.call_count >= 1
+
+ # Transport recovers; advance the clock by LESS than the interval.
+ upload.reset_mock()
+ upload.side_effect = None
+ clock["t"] = 1002.0 # 2s later, < 10s interval
+
+ # The next non-forced cycle must retry, not be rate-limited away.
+ mgr.sync()
+ assert upload.call_count == 3, (
+ "a failed sync must not rate-limit the next retry"
+ )
+
class TestEdgeCases:
def test_empty_file_list(self):
diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py
index 10eaef59695c..c535aa59a634 100644
--- a/tests/tools/test_kanban_tools.py
+++ b/tests/tools/test_kanban_tools.py
@@ -648,7 +648,9 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):
# Mock the judge to reject the completion. The gate only runs when a
# judge is reachable, so force the availability probe True as well.
def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None):
- return "continue", "missing verification evidence", False
+ # Match the real judge_goal contract:
+ # (verdict, reason, parse_failed, wait_directive, transport_failed)
+ return "continue", "missing verification evidence", False, None, False
monkeypatch.setattr("tools.kanban_tools.judge_goal", mock_judge_goal)
monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: True)
diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py
index f0138e9f83d7..3a5186f91424 100644
--- a/tests/tools/test_x_search_tool.py
+++ b/tests/tools/test_x_search_tool.py
@@ -70,8 +70,9 @@ def test_x_search_posts_responses_request(monkeypatch):
tool_def = captured["json"]["tools"][0]
assert captured["url"] == "https://api.x.ai/v1/responses"
assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{__version__}"
- assert captured["json"]["model"] == "grok-4.20-reasoning"
+ assert captured["json"]["model"] == "grok-4.5"
assert captured["json"]["store"] is False
+ assert "reasoning" not in captured["json"]
assert tool_def["type"] == "x_search"
assert tool_def["allowed_x_handles"] == ["xai", "grok"]
assert tool_def["from_date"] == "2026-04-01"
@@ -424,6 +425,50 @@ def test_x_search_honors_config_model_and_timeout(monkeypatch, tmp_path):
assert captured["timeout"] == 45
+def test_x_search_honors_config_reasoning_effort(monkeypatch, tmp_path):
+ """Configured reasoning effort reaches the xAI Responses request."""
+ from tools.x_search_tool import x_search_tool
+
+ monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ (tmp_path / "config.yaml").write_text(
+ "x_search:\n reasoning_effort: low\n retries: 0\n",
+ encoding="utf-8",
+ )
+ captured = {}
+
+ def _fake_post(url, headers=None, json=None, timeout=None):
+ assert json is not None
+ captured["reasoning"] = json.get("reasoning")
+ return _FakeResponse({"output_text": "Reasoning configured."})
+
+ monkeypatch.setattr("requests.post", _fake_post)
+
+ result = json.loads(x_search_tool(query="anything"))
+
+ assert result["success"] is True
+ assert captured["reasoning"] == {"effort": "low"}
+
+
+def test_x_search_rejects_invalid_config_reasoning_effort(monkeypatch):
+ """A typo must fail closed instead of silently using xAI's default effort."""
+ from tools.x_search_tool import x_search_tool
+
+ monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
+ monkeypatch.setattr(
+ "tools.x_search_tool._load_x_search_config",
+ lambda: {"reasoning_effort": "minimal"},
+ )
+ _no_post_allowed(monkeypatch)
+
+ result = json.loads(x_search_tool(query="anything"))
+
+ assert result["error"] == (
+ "x_search.reasoning_effort must be one of: low, medium, high, xhigh "
+ "(got 'minimal')"
+ )
+
+
def test_x_search_registered_in_registry_with_check_fn():
"""The tool is registered under the x_search toolset with the gating check_fn."""
import tools.x_search_tool # noqa: F401 — ensures registration runs
diff --git a/tests/tui_gateway/test_inline_rpc_gil_starvation.py b/tests/tui_gateway/test_inline_rpc_gil_starvation.py
index 99c63c746280..32aa60b0f00d 100644
--- a/tests/tui_gateway/test_inline_rpc_gil_starvation.py
+++ b/tests/tui_gateway/test_inline_rpc_gil_starvation.py
@@ -64,6 +64,7 @@ def capture(server):
# seconds when the GIL is contended by concurrent agent turns.
FRONTEND_POLLED_RPCS = [
+ "session.active_list", # live-session rehydrate — in-memory registry
"session.list", # loads session list — SQLite query
"pet.info", # petdex poll — file/network read
"process.list", # background process status — process registry scan
diff --git a/tests/tui_gateway/test_interim_assistant_callback.py b/tests/tui_gateway/test_interim_assistant_callback.py
new file mode 100644
index 000000000000..833c91413675
--- /dev/null
+++ b/tests/tui_gateway/test_interim_assistant_callback.py
@@ -0,0 +1,105 @@
+"""Tests for the interim_assistant_callback config gating in tui_gateway.
+
+These tests exercise the real _agent_cbs() wiring rather than a local
+imitation, so a break in the production callback registration is caught.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+
+def test_load_interim_assistant_messages_defaults_true():
+ from tui_gateway.server import _load_interim_assistant_messages
+
+ with patch("tui_gateway.server._load_cfg", return_value={}):
+ assert _load_interim_assistant_messages() is True
+
+
+def test_load_interim_assistant_messages_explicit_true():
+ from tui_gateway.server import _load_interim_assistant_messages
+
+ with patch("tui_gateway.server._load_cfg", return_value={"display": {"interim_assistant_messages": True}}):
+ assert _load_interim_assistant_messages() is True
+
+
+def test_load_interim_assistant_messages_explicit_false():
+ from tui_gateway.server import _load_interim_assistant_messages
+
+ with patch("tui_gateway.server._load_cfg", return_value={"display": {"interim_assistant_messages": False}}):
+ assert _load_interim_assistant_messages() is False
+
+
+def test_load_interim_assistant_messages_string_off():
+ from tui_gateway.server import _load_interim_assistant_messages
+
+ with patch("tui_gateway.server._load_cfg", return_value={"display": {"interim_assistant_messages": "off"}}):
+ assert _load_interim_assistant_messages() is False
+
+
+def test_agent_cbs_includes_interim_callback_when_enabled():
+ """_agent_cbs() includes interim_assistant_callback when the config is on.
+
+ Exercises the real _agent_cbs() wiring: the callback must be present in
+ the returned dict and, when invoked, must emit a message.interim event
+ with the text and already_streamed flag passed through.
+ """
+ from tui_gateway.server import _agent_cbs
+
+ emitted: list[tuple] = []
+
+ def fake_emit(event_type, sid, payload=None):
+ emitted.append((event_type, sid, payload))
+
+ with patch("tui_gateway.server._load_cfg", return_value={}), \
+ patch("tui_gateway.server._emit", side_effect=fake_emit):
+ cbs = _agent_cbs("test-session")
+
+ assert "interim_assistant_callback" in cbs
+ cb = cbs["interim_assistant_callback"]
+ assert callable(cb)
+
+ # Invoke the real callback inside the patch context — the lambda
+ # resolves _emit by name at call time, so it must be called while
+ # the patch is active.
+ cb("hello world", already_streamed=True)
+
+ assert len(emitted) == 1
+ assert emitted[0][0] == "message.interim"
+ assert emitted[0][1] == "test-session"
+ assert emitted[0][2]["text"] == "hello world"
+ assert emitted[0][2]["already_streamed"] is True
+
+
+def test_agent_cbs_omits_interim_callback_when_disabled():
+ """_agent_cbs() omits interim_assistant_callback when the config is off.
+
+ Exercises the real _agent_cbs() wiring: the callback must NOT be present
+ in the returned dict when display.interim_assistant_messages is false.
+ """
+ from tui_gateway.server import _agent_cbs
+
+ with patch("tui_gateway.server._load_cfg", return_value={"display": {"interim_assistant_messages": False}}):
+ cbs = _agent_cbs("test-session")
+
+ assert "interim_assistant_callback" not in cbs
+
+
+def test_agent_cbs_interim_callback_passes_already_streamed_false():
+ """The real callback passes already_streamed=False by default."""
+ from tui_gateway.server import _agent_cbs
+
+ emitted: list[tuple] = []
+
+ def fake_emit(event_type, sid, payload=None):
+ emitted.append((event_type, sid, payload))
+
+ with patch("tui_gateway.server._load_cfg", return_value={}), \
+ patch("tui_gateway.server._emit", side_effect=fake_emit):
+ cbs = _agent_cbs("test-session")
+
+ cb = cbs["interim_assistant_callback"]
+ cb("interim text")
+
+ assert emitted[0][2]["already_streamed"] is False
+ assert emitted[0][2]["text"] == "interim text"
diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py
index 4fcce61e2dce..2b51d785e103 100644
--- a/tests/tui_gateway/test_protocol.py
+++ b/tests/tui_gateway/test_protocol.py
@@ -352,6 +352,9 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -418,6 +421,9 @@ def test_session_resume_defaults_to_deferred_build(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -565,6 +571,9 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [multimodal_user, text_only_assistant]
@@ -621,6 +630,9 @@ def test_session_resume_lazy_registers_watch_session_without_agent(server, monke
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "delegated goal"},
@@ -700,6 +712,9 @@ def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypa
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [{"role": "user", "content": "delegated goal"}]
@@ -757,6 +772,9 @@ def test_session_resume_lazy_tolerates_missing_row_for_active_child(server, monk
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
# No rows for an unwritten session.
return []
@@ -860,6 +878,9 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch):
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return []
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
@@ -1083,6 +1104,9 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server,
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
+ def get_ancestor_display_prefix(self, _sid):
+ return list(ancestor_history)
+
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
if include_ancestors:
return ancestor_history + current_history
diff --git a/tools/async_delegation.py b/tools/async_delegation.py
index c743decf644e..6811d6867fdb 100644
--- a/tools/async_delegation.py
+++ b/tools/async_delegation.py
@@ -655,6 +655,7 @@ def dispatch_async_delegation_batch(
origin_ui_session_id: str = "",
interrupt_fn: Optional[Callable[[], None]] = None,
max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
+ delegation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Dispatch a WHOLE fan-out batch as ONE background unit.
@@ -676,7 +677,7 @@ def dispatch_async_delegation_batch(
``{"status": "rejected", "error": ...}`` when the async pool is at
capacity.
"""
- delegation_id = _new_delegation_id()
+ delegation_id = delegation_id or _new_delegation_id()
dispatched_at = time.time()
n = len(goals)
# A combined goal label for status listings / the completion header.
@@ -805,6 +806,10 @@ def _finalize_batch(
# The full per-task results list — the formatter renders a
# consolidated multi-task block from this.
"results": combined.get("results") or [],
+ # Per-task live transcript log paths (cache/delegation/live/...).
+ # They persist after completion and double as the full-fidelity
+ # operational record of each child's run.
+ "live_transcripts": combined.get("live_transcripts"),
"error": combined.get("error"),
"total_duration_seconds": combined.get("total_duration_seconds"),
"dispatched_at": dispatched_at,
diff --git a/tools/credential_files.py b/tools/credential_files.py
index 2a523532b4f8..583d9973b547 100644
--- a/tools/credential_files.py
+++ b/tools/credential_files.py
@@ -28,6 +28,11 @@ from pathlib import Path
from typing import Dict, List, Optional
from hermes_cli.config import cfg_get
+try: # pragma: no cover - exercised via the fail-closed test below
+ from agent.file_safety import get_read_block_error
+except ImportError: # noqa: F401 - sentinel consumed in register_credential_file
+ get_read_block_error = None # type: ignore[assignment]
+
logger = logging.getLogger(__name__)
# Session-scoped list of credential files to mount.
@@ -67,6 +72,15 @@ def register_credential_file(
The resolved host path must remain inside HERMES_HOME so that a malicious
skill cannot declare ``required_credential_files: ['../../.ssh/id_rsa']``
and exfiltrate sensitive host files into a container sandbox.
+
+ Containment alone is not sufficient, because HERMES_HOME is exactly where
+ the MASTER credential stores live. A skill legitimately needs its own
+ service token (``google_token.json``); it never needs ``.env`` (every
+ provider key), ``auth.json`` (all provider tokens and OAuth grants),
+ ``mcp-tokens/`` or the Bitwarden plaintext cache. Those are refused via
+ the canonical read deny-list (``agent.file_safety.get_read_block_error``)
+ — the same guard that stops the agent reading them with ``read_file``, so
+ the mount surface cannot hand a skill what the read surface denies it.
"""
hermes_home = _resolve_hermes_home()
@@ -98,6 +112,36 @@ def register_credential_file(
logger.debug("credential_files: skipping %s (not found)", resolved)
return False
+ # Master credential stores are never mountable, even though they sit
+ # inside HERMES_HOME and therefore pass the containment check above.
+ # Fails CLOSED: if the canonical guard can't be consulted we refuse the
+ # mount rather than risk bind-mounting auth.json into a sandbox. The
+ # import lives at module top (no circular-import concern — file_safety is
+ # stdlib-only); the sentinel + logger.exception keep guard failures
+ # debuggable instead of silently swallowed (#67665).
+ if get_read_block_error is None:
+ logger.error(
+ "credential_files: refusing %r — agent.file_safety could not be "
+ "imported, so the master-store deny-list cannot be consulted",
+ relative_path,
+ )
+ return False
+ try:
+ denied = get_read_block_error(str(resolved))
+ except Exception:
+ logger.exception(
+ "credential_files: refusing %r — read guard raised", relative_path
+ )
+ return False
+ if denied:
+ logger.warning(
+ "credential_files: refused %r — it is a credential store the agent "
+ "is denied from reading; a skill may mount its own service token, "
+ "not the master key files",
+ relative_path,
+ )
+ return False
+
container_path = f"{container_base.rstrip('/')}/{relative_path}"
_get_registered()[container_path] = str(resolved)
logger.debug("credential_files: registered %s -> %s", resolved, container_path)
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index 49ffb4980c3c..b7ec26e20055 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -2575,6 +2575,21 @@ def delegate_task(
# Track goal labels for progress display (truncated for readability)
task_labels = [t["goal"][:40] for t in task_list]
+ # Live transcripts: one pre-headered append-only log per task under
+ # cache/delegation/live//task-.log so the caller can
+ # tail each child's operations while it runs (side-channel only — zero
+ # effect on message content or prompt caching). Best-effort: on failure
+ # live_paths is empty and delegation proceeds exactly as before.
+ from tools.delegation_live_log import (
+ create_live_transcripts,
+ update_manifest_statuses,
+ wrap_progress_callback,
+ )
+
+ live_deleg_id, live_writers, live_paths = create_live_transcripts(
+ task_list, context
+ )
+
# Save parent tool names BEFORE any child construction mutates the global.
# _build_child_agent() calls AIAgent() which calls get_tool_definitions(),
# which overwrites model_tools._last_resolved_tool_names with child's toolset.
@@ -2614,6 +2629,17 @@ def delegate_task(
)
# Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
+ # Tee the child's progress events into its live transcript log.
+ # wrap_progress_callback preserves the inner callback contract
+ # (including the _flush attribute) and never lets writer failures
+ # reach the agent loop. When no parent display exists the inner
+ # callback is None and the wrapper still records events.
+ _writer = live_writers[i] if i < len(live_writers) else None
+ if _writer is not None:
+ child.tool_progress_callback = wrap_progress_callback(
+ getattr(child, "tool_progress_callback", None), _writer
+ )
+ child._live_transcript_path = str(_writer.path)
children.append((i, t, child))
finally:
# Authoritative restore: reset global to parent's tool names after all children built
@@ -2861,10 +2887,33 @@ def delegate_task(
total_duration = round(time.monotonic() - overall_start, 2)
- return {
+ # Close out the live transcripts: terminal marker per task + manifest
+ # status update. The files are retained (retention pruning happens on
+ # future dispatches) — they double as the full-fidelity operational
+ # record alongside the summary spill files.
+ for entry in results:
+ _idx = entry.get("task_index", -1)
+ _w = (
+ live_writers[_idx]
+ if isinstance(_idx, int) and 0 <= _idx < len(live_writers)
+ else None
+ )
+ if _w is not None:
+ try:
+ _w.finalize(entry)
+ except Exception:
+ logger.debug("Live transcript finalize failed", exc_info=True)
+ if _idx < len(live_paths):
+ entry["live_transcript"] = live_paths[_idx]
+ update_manifest_statuses(live_deleg_id, results)
+
+ combined: Dict[str, Any] = {
"results": results,
"total_duration_seconds": total_duration,
}
+ if live_paths:
+ combined["live_transcripts"] = list(live_paths)
+ return combined
# ----- Background dispatch: run the WHOLE batch as one async unit -----
# When background is true, the entire fan-out runs on the daemon executor
@@ -2986,6 +3035,9 @@ def delegate_task(
runner=_batch_runner,
interrupt_fn=_batch_interrupt,
max_async_children=_get_max_async_children(),
+ # Reuse the live-transcript directory's id (when created) so the
+ # returned delegation_id matches cache/delegation/live//.
+ delegation_id=live_deleg_id,
)
if dispatch.get("status") == "dispatched":
@@ -3010,6 +3062,14 @@ def delegate_task(
"goals": _goals,
"note": note,
}
+ if live_paths:
+ payload["live_transcripts"] = list(live_paths)
+ payload["live_transcripts_hint"] = (
+ "Each subagent streams a human-readable transcript of its "
+ "operations to the file listed above (append-only, one per "
+ "task). Read or `tail -f` these paths at any time to watch "
+ "a child work while it runs."
+ )
return json.dumps(payload, ensure_ascii=False)
# Pool at capacity / schedule failure — children are still attached
@@ -3352,6 +3412,13 @@ def _build_top_level_description() -> str:
"batch returns one handle, runs N subagents concurrently, and delivers "
"one consolidated result after ALL of them finish. Do NOT wait or poll; "
"just continue with other work after dispatching.\n\n"
+ "LIVE TRANSCRIPTS: the dispatch response includes 'live_transcripts' — "
+ "one append-only human-readable log file per task (under "
+ "cache/delegation/live//). Each child streams its "
+ "assistant text, tool calls, and tool results there while it runs. "
+ "Read (or `tail -f` in a terminal) those paths any time you or the "
+ "user want to see what a subagent is actually doing instead of "
+ "waiting for the final summary.\n\n"
"WHEN TO USE delegate_task:\n"
"- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n"
"- Tasks that would flood your context with intermediate data\n"
diff --git a/tools/delegation_live_log.py b/tools/delegation_live_log.py
new file mode 100644
index 000000000000..86415c96c722
--- /dev/null
+++ b/tools/delegation_live_log.py
@@ -0,0 +1,424 @@
+"""Live, tail-able transcripts for delegated subagents.
+
+Every ``delegate_task`` dispatch creates one append-only, human-readable log
+per child under::
+
+ /cache/delegation/live//task-.log
+
+The files are pre-created with a header at dispatch time (so ``tail -f``
+attaches immediately) and then stream one line per child event: assistant
+text, thinking, tool calls, tool results, and lifecycle markers. The paths
+are returned from ``delegate_task`` so the parent agent (or the user) can
+watch a child work instead of waiting blind for the consolidated summary.
+
+Placement under ``cache/delegation`` is deliberate: that directory is
+mounted read-only into remote terminal backends (Docker/Modal/SSH) via
+``credential_files._CACHE_DIRS``, so the logs are readable from any backend.
+
+Design constraints:
+
+* **Never raise into the agent loop.** Every write is wrapped; the first
+ failure disables the writer and degrades to a debug log.
+* **Survive child crashes.** Files are opened in append mode per write —
+ no long-lived handle to lose, every event is flushed when written.
+* **Side-channel only.** Nothing here touches message content, so prompt
+ caching is unaffected.
+* **No config knobs.** Retention is a module constant (7 days), pruned
+ opportunistically on each new dispatch.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+import threading
+import time
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+# Live transcript directories older than this are pruned on new dispatches.
+LIVE_RETENTION_DAYS = 7
+
+# Per-line truncation budgets (chars). The .log is a compact operational
+# view, not the full-fidelity record — the child's SessionDB transcript and
+# the summary spill files carry complete text.
+_ASSISTANT_MAX = 600
+_THINKING_MAX = 300
+_ARGS_MAX = 220
+_RESULT_MAX = 400
+_KICKOFF_MAX = 500
+
+# Stream deltas are buffered and flushed as one assistant line when another
+# event type arrives (or on completion). Cap the buffer so a huge streamed
+# reply can't hold memory hostage.
+_STREAM_BUFFER_FLUSH_CHARS = 4000
+
+
+def live_transcript_root() -> Path:
+ """Root directory for live transcripts (profile-safe, never ~/.hermes)."""
+ from hermes_constants import get_hermes_dir
+
+ return get_hermes_dir("cache/delegation", "delegation_cache") / "live"
+
+
+def new_live_delegation_id() -> str:
+ """Same shape as async_delegation's ids so the dir name matches the handle."""
+ return f"deleg_{uuid.uuid4().hex[:8]}"
+
+
+def _one_line(text: Any, limit: int) -> str:
+ """Collapse to a single line and truncate with an elided-chars note."""
+ s = str(text or "")
+ s = " ".join(s.split()) # collapse newlines/runs of whitespace
+ if len(s) > limit:
+ omitted = len(s) - limit
+ s = s[:limit] + f" …(+{omitted} chars)"
+ return s
+
+
+def _redact(text: str) -> str:
+ """Mask credentials before anything reaches the transcript file.
+
+ These logs live under ``cache/delegation``, which ``delegate_tool`` mounts
+ READ-ONLY into remote terminal backends — so every line written here is
+ readable from inside the sandbox. The events rendered here carry exactly
+ the data that tends to hold secrets: tool args (a bearer header on a
+ curl), tool results (a ``.env`` dump, a provider error echoing the key
+ back) and streamed assistant text. Every other sink for that data already
+ routes through this same redactor — search results via
+ ``redact_sensitive_text``, terminal output via ``redact_terminal_output``
+ — so a transcript that skipped it is the one place the operator's keys
+ land in plaintext.
+
+ ``force=True``: this is a safety boundary, so it must redact even when the
+ global toggle is off. Withholds the line rather than emitting raw text if
+ the redactor is somehow unavailable — losing a debug line costs less than
+ writing a live credential into a sandbox-readable file.
+ """
+ if not text:
+ return text
+ try:
+ from agent.redact import redact_sensitive_text
+
+ return redact_sensitive_text(text, force=True) or ""
+ except Exception: # pragma: no cover - core module; never leak on failure
+ return "[line withheld: redaction unavailable]"
+
+
+class LiveTranscriptWriter:
+ """Append-only human-readable event log for ONE subagent task.
+
+ All methods are best-effort: the first write failure flips ``_ok`` off
+ and subsequent calls become no-ops (debug-logged). Never raises.
+ """
+
+ def __init__(self, delegation_id: str, task_index: int, goal: str,
+ context: Optional[str] = None, root: Optional[Path] = None):
+ self.delegation_id = delegation_id
+ self.task_index = task_index
+ self._ok = True
+ self._lock = threading.Lock()
+ self._stream_buf: List[str] = []
+ self._stream_len = 0
+ try:
+ base = (root if root is not None else live_transcript_root())
+ d = base / delegation_id
+ d.mkdir(parents=True, exist_ok=True)
+ self.path: Optional[Path] = d / f"task-{task_index}.log"
+ header = [
+ "=== Hermes subagent live transcript ===",
+ f"delegation: {delegation_id} task: {task_index}",
+ # Header bypasses event(), so redact here too — a goal string
+ # can carry a key the caller pasted into the task.
+ f"goal: {_redact(_one_line(goal, _KICKOFF_MAX))}",
+ f"started: {time.strftime('%Y-%m-%d %H:%M:%S')}",
+ "(append-only; streams while the subagent runs — tail -f me)",
+ "=" * 40,
+ ]
+ self.path.write_text("\n".join(header) + "\n", encoding="utf-8")
+ self.event("user", "kickoff: " + _one_line(goal, _KICKOFF_MAX)
+ + (f" | context: {_one_line(context, _KICKOFF_MAX)}" if context else ""))
+ except Exception as exc:
+ logger.debug("Live transcript init failed (%s task %s): %s",
+ delegation_id, task_index, exc)
+ self._ok = False
+ self.path = None
+
+ # ── low-level ────────────────────────────────────────────────────────
+ def event(self, role: str, text: str) -> None:
+ """Append one ``HH:MM:SS role ⟩ text`` line. Flushed per event."""
+ if not self._ok or self.path is None:
+ return
+ # Single choke point: every typed helper funnels through here, so
+ # redacting once covers args, results, thinking and streamed text —
+ # and a helper added later can't bypass it.
+ line = f"{time.strftime('%H:%M:%S')} {role:<9}| {_redact(text)}\n"
+ try:
+ with self._lock:
+ # Append mode per write: no held handle, survives child crash,
+ # and the close() acts as the flush.
+ with open(self.path, "a", encoding="utf-8") as fh:
+ fh.write(line)
+ except Exception as exc:
+ self._ok = False
+ logger.debug("Live transcript write failed (%s): %s", self.path, exc)
+
+ # ── typed helpers ────────────────────────────────────────────────────
+ def assistant_text(self, text: str) -> None:
+ t = _one_line(text, _ASSISTANT_MAX)
+ if t:
+ self.event("assistant", t)
+
+ def thinking(self, text: str) -> None:
+ t = _one_line(text, _THINKING_MAX)
+ if t:
+ self.event("think", t)
+
+ def tool_start(self, name: str, args_preview: Any = None) -> None:
+ self.flush_stream()
+ args = _one_line(args_preview, _ARGS_MAX)
+ self.event("tool", f"-> {name or '?'}({args})")
+
+ def tool_result(self, name: str, result: Any = None,
+ duration: Any = None, is_error: bool = False) -> None:
+ status = "ERROR" if is_error else "ok"
+ dur = ""
+ try:
+ if duration is not None:
+ dur = f" {float(duration):.1f}s"
+ except (TypeError, ValueError):
+ pass
+ self.event("result", f"{name or '?'} {status}{dur}: "
+ f"{_one_line(result, _RESULT_MAX)}")
+
+ def marker(self, text: str) -> None:
+ """Lifecycle marker: start / final / error / interrupt / budget."""
+ self.flush_stream()
+ self.event("final", _one_line(text, _ASSISTANT_MAX))
+
+ # ── streamed reply buffering ─────────────────────────────────────────
+ def add_stream_delta(self, delta: str) -> None:
+ """Buffer streamed assistant reply text; flushed as one line."""
+ if not delta or not self._ok:
+ return
+ self._stream_buf.append(delta)
+ self._stream_len += len(delta)
+ if self._stream_len >= _STREAM_BUFFER_FLUSH_CHARS:
+ self.flush_stream()
+
+ def flush_stream(self) -> None:
+ if not self._stream_buf:
+ return
+ text = "".join(self._stream_buf)
+ self._stream_buf = []
+ self._stream_len = 0
+ self.assistant_text(text)
+
+ # ── event demux (the tool_progress_callback surface) ─────────────────
+ def observe(self, event_type: Any, tool_name: Any = None,
+ preview: Any = None, args: Any = None, **kwargs: Any) -> None:
+ """Map a child tool_progress_callback event onto transcript lines.
+
+ Mirrors the shapes emitted by agent/tool_executor.py,
+ agent/conversation_loop.py, and tools/delegate_tool._run_single_child.
+ Unknown events are ignored. Never raises (event() swallows I/O).
+ """
+ et = str(event_type or "")
+ if et == "tool.started":
+ self.tool_start(str(tool_name or ""), preview if preview else args)
+ elif et == "tool.completed":
+ self.tool_result(
+ str(tool_name or ""),
+ result=kwargs.get("result"),
+ duration=kwargs.get("duration"),
+ is_error=bool(kwargs.get("is_error")),
+ )
+ elif et == "_thinking":
+ # Fired as cb("_thinking", ) — the text rides in the
+ # tool_name positional slot (see conversation_loop.py).
+ self.thinking(str(tool_name or preview or ""))
+ elif et == "reasoning.available":
+ # cb("reasoning.available", "_thinking", , None)
+ self.thinking(str(preview or ""))
+ elif et == "subagent.text":
+ self.add_stream_delta(str(preview or ""))
+ elif et == "subagent.start":
+ self.event("start", _one_line(preview, _KICKOFF_MAX))
+ elif et == "subagent.complete":
+ self.flush_stream()
+ status = kwargs.get("status", "?")
+ dur = kwargs.get("duration_seconds")
+ parts = [f"status={status}"]
+ if dur is not None:
+ parts.append(f"duration={dur}s")
+ summary = kwargs.get("summary") or preview
+ if summary:
+ parts.append(f"summary: {_one_line(summary, _RESULT_MAX)}")
+ self.marker(" ".join(parts))
+
+ def finalize(self, entry: Dict[str, Any]) -> None:
+ """Terminal marker from the aggregated result entry.
+
+ Adds exit-reason detail the subagent.complete event doesn't carry
+ (budget exhaustion via exit_reason=max_iterations, errors, etc.).
+ """
+ parts = [f"end status={entry.get('status', '?')}"]
+ exit_reason = entry.get("exit_reason")
+ if exit_reason:
+ parts.append(f"exit_reason={exit_reason}")
+ if exit_reason == "max_iterations":
+ parts.append("(iteration budget exhausted)")
+ if entry.get("error"):
+ parts.append(f"error: {_one_line(entry['error'], _RESULT_MAX)}")
+ self.marker(" ".join(parts))
+
+
+def wrap_progress_callback(inner_cb, writer: LiveTranscriptWriter):
+ """Wrap a child's tool_progress_callback so events also land in the log.
+
+ ``inner_cb`` may be None (no parent display) — the wrapper still records.
+ Writer failures never propagate; inner callback behavior is unchanged
+ (its own exceptions are handled by callers exactly as before).
+ Preserves the ``_flush`` attribute contract used by _run_single_child.
+ """
+
+ def _cb(event_type, tool_name=None, preview=None, args=None, **kwargs):
+ try:
+ writer.observe(event_type, tool_name, preview, args, **kwargs)
+ except Exception as exc: # noqa: BLE001 — must never hit the agent loop
+ logger.debug("Live transcript observe failed: %s", exc)
+ if inner_cb is not None:
+ inner_cb(event_type, tool_name, preview, args, **kwargs)
+
+ def _flush():
+ try:
+ writer.flush_stream()
+ except Exception:
+ pass
+ inner_flush = getattr(inner_cb, "_flush", None)
+ if callable(inner_flush):
+ inner_flush()
+
+ _cb._flush = _flush
+ return _cb
+
+
+# ── dispatch-time helpers ────────────────────────────────────────────────
+
+def create_live_transcripts(
+ task_list: List[Dict[str, Any]],
+ context: Optional[str] = None,
+ delegation_id: Optional[str] = None,
+) -> tuple[Optional[str], List[Optional[LiveTranscriptWriter]], List[str]]:
+ """Create one pre-headered writer per task + a manifest.json.
+
+ Returns ``(delegation_id, writers, paths)``. On any top-level failure
+ returns ``(None, [None]*n, [])`` so delegation proceeds untouched.
+ Also opportunistically prunes stale live dirs (retention).
+ """
+ n = len(task_list)
+ try:
+ prune_stale_live_dirs()
+ except Exception:
+ pass
+ try:
+ deleg_id = delegation_id or new_live_delegation_id()
+ writers: List[Optional[LiveTranscriptWriter]] = []
+ paths: List[str] = []
+ for i, t in enumerate(task_list):
+ w = LiveTranscriptWriter(
+ deleg_id, i, str(t.get("goal", "")),
+ context=t.get("context") or context,
+ )
+ writers.append(w if w.path is not None else None)
+ if w.path is not None:
+ paths.append(str(w.path))
+ if not paths:
+ return None, [None] * n, []
+ _write_manifest(deleg_id, task_list, paths)
+ return deleg_id, writers, paths
+ except Exception as exc:
+ logger.debug("Live transcript creation failed: %s", exc)
+ return None, [None] * n, []
+
+
+def _manifest_path(delegation_id: str) -> Path:
+ return live_transcript_root() / delegation_id / "manifest.json"
+
+
+def _write_manifest(delegation_id: str, task_list: List[Dict[str, Any]],
+ paths: List[str]) -> None:
+ try:
+ manifest = {
+ "delegation_id": delegation_id,
+ "started": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "task_count": len(task_list),
+ "tasks": [
+ {
+ "index": i,
+ # manifest.json sits in the same mounted
+ # cache/delegation/live// directory as the .log files,
+ # so it needs the same treatment — redacting the header
+ # while serialising the goal verbatim here would leave the
+ # credential exposed one file over.
+ "goal": _redact(str(t.get("goal", ""))[:500]),
+ "log": paths[i] if i < len(paths) else None,
+ "status": "running",
+ }
+ for i, t in enumerate(task_list)
+ ],
+ }
+ _manifest_path(delegation_id).write_text(
+ json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8"
+ )
+ except Exception as exc:
+ logger.debug("Live transcript manifest write failed: %s", exc)
+
+
+def update_manifest_statuses(delegation_id: Optional[str],
+ results: List[Dict[str, Any]]) -> None:
+ """Best-effort per-task status update once the batch has aggregated."""
+ if not delegation_id:
+ return
+ try:
+ mp = _manifest_path(delegation_id)
+ manifest = json.loads(mp.read_text(encoding="utf-8"))
+ by_index = {r.get("task_index"): r for r in results if isinstance(r, dict)}
+ for task in manifest.get("tasks", []):
+ r = by_index.get(task.get("index"))
+ if r is not None:
+ task["status"] = r.get("status", task.get("status"))
+ if r.get("exit_reason"):
+ task["exit_reason"] = r["exit_reason"]
+ manifest["completed"] = time.strftime("%Y-%m-%d %H:%M:%S")
+ mp.write_text(json.dumps(manifest, indent=2, ensure_ascii=False),
+ encoding="utf-8")
+ except Exception as exc:
+ logger.debug("Live transcript manifest update failed: %s", exc)
+
+
+def prune_stale_live_dirs(max_age_days: int = LIVE_RETENTION_DAYS) -> int:
+ """Remove live/ dirs older than the retention window.
+
+ Returns how many were removed. Fully best-effort.
+ """
+ removed = 0
+ try:
+ root = live_transcript_root()
+ if not root.is_dir():
+ return 0
+ cutoff = time.time() - max_age_days * 86400
+ for child in root.iterdir():
+ try:
+ if child.is_dir() and child.stat().st_mtime < cutoff:
+ shutil.rmtree(child, ignore_errors=True)
+ removed += 1
+ except OSError:
+ continue
+ except Exception as exc:
+ logger.debug("Live transcript pruning failed: %s", exc)
+ return removed
diff --git a/tools/env_probe.py b/tools/env_probe.py
index 71a1c8116cf4..95427cc1c9af 100644
--- a/tools/env_probe.py
+++ b/tools/env_probe.py
@@ -34,6 +34,7 @@ import os
import shutil
import subprocess
import sys
+import tempfile
import threading
from typing import Optional
@@ -42,8 +43,30 @@ logger = logging.getLogger(__name__)
# Module-level cache. The probe result is deterministic for the
# lifetime of the process — Python install state doesn't change
# mid-session in any way that would matter for the system prompt.
+#
+# Concurrency model (#67964): the probe runs in exactly ONE background
+# worker thread; ``_PROBE_DONE`` signals completion. Callers never
+# execute the probe themselves and never wait unboundedly — they block
+# at most ``_PROBE_WAIT_TIMEOUT`` seconds on the event and then fail
+# open with "". This guarantees a stuck probe (e.g. a Windows pipe
+# wedged open by an orphaned pip descendant) can degrade at most the
+# probe line itself, never system-prompt construction.
_CACHE_LOCK = threading.Lock()
_CACHED_LINE: Optional[str] = None # None = not probed yet; "" = probed, nothing to say.
+_PROBE_DONE = threading.Event()
+_PROBE_THREAD: Optional[threading.Thread] = None
+# Generation counter — bumped on every reset so a stale worker (started
+# before a test reset) can't publish its result into the fresh generation.
+_PROBE_GEN = 0
+
+# Upper bound a prompt build will wait for the probe. Generous vs the
+# ~0.5s healthy runtime (6 subprocesses × 3s timeout ≈ 18s pathological
+# worst case), but finite: prompt construction must always proceed.
+_PROBE_WAIT_TIMEOUT = 10.0
+# Once one caller has burned the full wait and given up, later callers
+# stop paying it too — they just peek at the event. If the stuck worker
+# ever finishes, the published line resumes appearing in new prompts.
+_WAIT_ALREADY_TIMED_OUT = False
# Remote backends — keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS.
# Duplicated rather than imported to avoid a circular import (prompt_builder
@@ -57,21 +80,41 @@ def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
"""Run a short subprocess. Returns (returncode, stdout, stderr).
Failures (binary missing, timeout, OSError) return (-1, "", "").
+
+ Output is captured through temporary files rather than ``capture_output``
+ pipes so ``timeout`` bounds the *whole* call — even on native Windows. A
+ console-script launcher (e.g. ``pip.exe``) can spawn a descendant that
+ inherits the captured stdout/stderr handles and outlives its parent. With
+ OS pipes, the reader threads inside ``subprocess.communicate()`` then block
+ until that descendant closes the write end — which the timeout does *not*
+ cover, because killing the direct child leaves the grandchild holding the
+ pipe. A whole warm probe could hang for ~28 min this way while holding
+ ``_CACHE_LOCK``, wedging every new session's system-prompt build.
+
+ Temp files have no reader threads, so ``wait()`` only ever waits on the
+ direct child; a lingering grandchild holding the handle can't block us, and
+ the probe genuinely fails open on timeout.
"""
try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- timeout=timeout,
- check=False,
- stdin=subprocess.DEVNULL,
- )
- return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
+ with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
+ try:
+ result = subprocess.run(
+ cmd,
+ stdout=out_f,
+ stderr=err_f,
+ timeout=timeout,
+ check=False,
+ stdin=subprocess.DEVNULL,
+ )
+ except subprocess.TimeoutExpired:
+ return -1, "", "timeout"
+ out_f.seek(0)
+ err_f.seek(0)
+ out = out_f.read().decode("utf-8", "replace").strip()
+ err = err_f.read().decode("utf-8", "replace").strip()
+ return result.returncode, out, err
except FileNotFoundError:
return -1, "", "not found"
- except subprocess.TimeoutExpired:
- return -1, "", "timeout"
except OSError as exc:
return -1, "", f"oserror: {exc}"
@@ -219,29 +262,74 @@ def get_environment_probe_line(*, force_refresh: bool = False) -> str:
assembler should drop the section in that case rather than
emit an empty heading.
+ The probe itself always runs in a single background worker thread;
+ this function waits on its completion event for at most
+ ``_PROBE_WAIT_TIMEOUT`` seconds and then fails open with "". A
+ wedged probe subprocess (#67964) therefore can never block
+ system-prompt construction — at worst the toolchain line is absent
+ from prompts built while the probe is stuck.
+
``force_refresh`` is for tests; real callers should never need it.
"""
- global _CACHED_LINE
+ global _CACHED_LINE, _PROBE_THREAD, _PROBE_GEN, _WAIT_ALREADY_TIMED_OUT
if force_refresh:
with _CACHE_LOCK:
_CACHED_LINE = None
+ _PROBE_DONE.clear()
+ _PROBE_THREAD = None
+ _PROBE_GEN += 1
+ _WAIT_ALREADY_TIMED_OUT = False
- if _CACHED_LINE is not None:
- return _CACHED_LINE
+ if _PROBE_DONE.is_set():
+ return _CACHED_LINE or ""
+ _ensure_probe_started()
+ wait_timeout = 0.05 if _WAIT_ALREADY_TIMED_OUT else _PROBE_WAIT_TIMEOUT
+ if not _PROBE_DONE.wait(timeout=wait_timeout):
+ # Probe stuck or pathologically slow. The line is a nice-to-have;
+ # blocking prompt construction is an outage. Fail open — if the
+ # worker eventually finishes, sessions started later get the line.
+ if not _WAIT_ALREADY_TIMED_OUT:
+ _WAIT_ALREADY_TIMED_OUT = True
+ logger.warning(
+ "env_probe did not finish within %.0fs; building the system "
+ "prompt without the Python toolchain line",
+ _PROBE_WAIT_TIMEOUT,
+ )
+ return ""
+ return _CACHED_LINE or ""
+
+
+def _probe_worker(gen: int) -> None:
+ """Body of the single probe thread — computes and publishes the line."""
+ global _CACHED_LINE
+ try:
+ line = _build_probe_line()
+ except Exception as exc: # never let probe failure propagate
+ logger.debug("env_probe failed: %s", exc)
+ line = ""
with _CACHE_LOCK:
- if _CACHED_LINE is not None: # raced
- return _CACHED_LINE
- try:
- line = _build_probe_line()
- except Exception as exc: # never let probe failure block prompt build
- logger.debug("env_probe failed: %s", exc)
- line = ""
+ if gen != _PROBE_GEN:
+ return # superseded by a reset (tests) — discard stale result
_CACHED_LINE = line
- return line
+ _PROBE_DONE.set()
-_warm_started = False
+def _ensure_probe_started() -> None:
+ """Start the probe worker if it isn't running and hasn't finished."""
+ global _PROBE_THREAD
+ with _CACHE_LOCK:
+ if _PROBE_DONE.is_set():
+ return
+ if _PROBE_THREAD is not None and _PROBE_THREAD.is_alive():
+ return
+ _PROBE_THREAD = threading.Thread(
+ target=_probe_worker,
+ args=(_PROBE_GEN,),
+ name="env-probe",
+ daemon=True,
+ )
+ _PROBE_THREAD.start()
def warm_environment_probe_async() -> None:
@@ -251,25 +339,19 @@ def warm_environment_probe_async() -> None:
critical path.
Idempotent and fail-safe. The prompt-build call to
- ``get_environment_probe_line`` takes the same ``_CACHE_LOCK``, so it
- blocks only for whatever remains of an in-flight warm instead of
- recomputing. Called from agent init (all platforms); safe to call
- from anywhere.
+ ``get_environment_probe_line`` waits (bounded) on the same worker's
+ completion event instead of recomputing. Called from agent init
+ (all platforms); safe to call from anywhere.
"""
- global _warm_started
- if _warm_started or _CACHED_LINE is not None:
- return
- _warm_started = True
- threading.Thread(
- target=get_environment_probe_line,
- name="env-probe-warm",
- daemon=True,
- ).start()
+ _ensure_probe_started()
def _reset_cache_for_tests() -> None:
"""Test helper — clear the cache between probe scenarios."""
- global _CACHED_LINE, _warm_started
+ global _CACHED_LINE, _PROBE_THREAD, _PROBE_GEN, _WAIT_ALREADY_TIMED_OUT
with _CACHE_LOCK:
_CACHED_LINE = None
- _warm_started = False
+ _PROBE_DONE.clear()
+ _PROBE_THREAD = None
+ _PROBE_GEN += 1
+ _WAIT_ALREADY_TIMED_OUT = False
diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py
index 0c7819712ac4..2357228ef9c5 100644
--- a/tools/environments/file_sync.py
+++ b/tools/environments/file_sync.py
@@ -35,6 +35,9 @@ logger = logging.getLogger(__name__)
# ``time.sleep`` globally because ``time`` is the module object; under xdist
# that lets unrelated background threads inflate retry-test call counts.
_sleep = time.sleep
+# Same rationale for the rate-limit clock: tests patch ``_monotonic``
+# instead of ``time.monotonic`` on the shared module object.
+_monotonic = time.monotonic
_SYNC_INTERVAL_SECONDS = 5.0
_FORCE_SYNC_ENV = "HERMES_FORCE_FILE_SYNC"
@@ -169,7 +172,7 @@ class FileSyncManager:
On failure, state rolls back so the next cycle retries everything.
"""
if not force and not os.environ.get(_FORCE_SYNC_ENV):
- now = time.monotonic()
+ now = _monotonic()
if now - self._last_sync_time < self._sync_interval:
return
@@ -193,7 +196,7 @@ class FileSyncManager:
to_delete = [p for p in self._synced_files if p not in current_remote_paths]
if not to_upload and not to_delete:
- self._last_sync_time = time.monotonic()
+ self._last_sync_time = _monotonic()
return
# Snapshot for rollback (only when there's work to do)
@@ -227,12 +230,17 @@ class FileSyncManager:
self._pushed_hashes.pop(p, None)
self._synced_files = new_files
- self._last_sync_time = time.monotonic()
+ self._last_sync_time = _monotonic()
except Exception as exc:
self._synced_files = prev_files
self._pushed_hashes = prev_hashes
- self._last_sync_time = time.monotonic()
+ # Do NOT advance _last_sync_time here: a failed cycle rolls state
+ # back so the next cycle can retry. Bumping the rate-limit clock on
+ # failure would make the next non-forced sync() return early (the
+ # guard above), suppressing that retry for up to _sync_interval and
+ # leaving the remote with stale files — contradicting this method's
+ # documented "next cycle retries everything" contract.
logger.warning("file_sync: sync failed, rolled back state: %s", exc)
# ------------------------------------------------------------------
diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py
index 8d27522f1829..5535c01e2e68 100644
--- a/tools/kanban_tools.py
+++ b/tools/kanban_tools.py
@@ -602,7 +602,12 @@ def _handle_complete(args: dict, **kw) -> str:
verdict = "done"
reason = ""
try:
- verdict, reason, _ = judge_goal(
+ # judge_goal returns (verdict, reason, parse_failed,
+ # wait_directive, transport_failed) — see
+ # hermes_cli/goals.py. Unpacking fewer raises ValueError,
+ # which the defensive handler below swallows, leaving
+ # verdict="done" and silently disabling the gate.
+ verdict, reason, _, _, _ = judge_goal(
goal=f"{task.title}\n\n{task.body or ''}".strip(),
last_response=(summary or result or "").strip(),
)
diff --git a/tools/memory_tool.py b/tools/memory_tool.py
index 08eeaa470ea4..f5b866b80ac7 100644
--- a/tools/memory_tool.py
+++ b/tools/memory_tool.py
@@ -56,6 +56,16 @@ def get_memory_dir() -> Path:
"""Return the profile-scoped memories directory."""
return get_hermes_home() / "memories"
+# Stable header prefixes for the system-prompt memory blocks rendered by
+# MemoryStore._render_block. Exported so compression's prompt-retention check
+# (agent/conversation_compression.py) can detect a leftover block for a
+# target whose entries have since been emptied — keep in lockstep with
+# _render_block below.
+MEMORY_BLOCK_HEADERS = {
+ "memory": "MEMORY (your personal notes)",
+ "user": "USER PROFILE (who the user is)",
+}
+
ENTRY_DELIMITER = "\n§\n"
@@ -672,9 +682,9 @@ class MemoryStore:
pct = min(100, int((current / limit) * 100)) if limit > 0 else 0
if target == "user":
- header = f"USER PROFILE (who the user is) [{pct}% — {current:,}/{limit:,} chars]"
+ header = f"{MEMORY_BLOCK_HEADERS['user']} [{pct}% — {current:,}/{limit:,} chars]"
else:
- header = f"MEMORY (your personal notes) [{pct}% — {current:,}/{limit:,} chars]"
+ header = f"{MEMORY_BLOCK_HEADERS['memory']} [{pct}% — {current:,}/{limit:,} chars]"
separator = "═" * 46
return f"{separator}\n{header}\n{separator}\n{content}"
diff --git a/tools/process_registry.py b/tools/process_registry.py
index 0c7616db0630..97cf89b3bda8 100644
--- a/tools/process_registry.py
+++ b/tools/process_registry.py
@@ -2119,6 +2119,11 @@ def _format_async_delegation(evt: dict) -> str:
+ (f": {r_error}" if r_error else "")
+ ")"
)
+ r_live = r.get("live_transcript")
+ if r_live:
+ lines.append(
+ f"Full live transcript (complete tool/assistant trace): {r_live}"
+ )
return "\n".join(lines)
age = ""
diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py
index 39ecf2daf33b..87de533dd4ac 100644
--- a/tools/x_search_tool.py
+++ b/tools/x_search_tool.py
@@ -56,9 +56,10 @@ from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
logger = logging.getLogger(__name__)
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
-DEFAULT_X_SEARCH_MODEL = "grok-4.20-reasoning"
+DEFAULT_X_SEARCH_MODEL = "grok-4.5"
DEFAULT_X_SEARCH_TIMEOUT_SECONDS = 180
DEFAULT_X_SEARCH_RETRIES = 2
+X_SEARCH_REASONING_EFFORTS = ("low", "medium", "high", "xhigh")
MAX_HANDLES = 10
@@ -80,6 +81,22 @@ def _get_x_search_model() -> str:
return (str(cfg.get("model") or "").strip() or DEFAULT_X_SEARCH_MODEL)
+def _get_x_search_reasoning_effort() -> Optional[str]:
+ cfg = _load_x_search_config()
+ raw_value = cfg.get("reasoning_effort")
+ if raw_value is None or not str(raw_value).strip():
+ return None
+
+ effort = str(raw_value).strip().lower()
+ if effort not in X_SEARCH_REASONING_EFFORTS:
+ allowed = ", ".join(X_SEARCH_REASONING_EFFORTS)
+ raise ValueError(
+ f"x_search.reasoning_effort must be one of: {allowed} "
+ f"(got {raw_value!r})"
+ )
+ return effort
+
+
def _get_x_search_timeout_seconds() -> int:
cfg = _load_x_search_config()
raw_value = cfg.get("timeout_seconds", DEFAULT_X_SEARCH_TIMEOUT_SECONDS)
@@ -299,6 +316,11 @@ def x_search_tool(
except ValueError as exc:
return tool_error(str(exc))
+ try:
+ reasoning_effort = _get_x_search_reasoning_effort()
+ except ValueError as exc:
+ return tool_error(str(exc))
+
tool_def: Dict[str, Any] = {"type": "x_search"}
if allowed:
tool_def["allowed_x_handles"] = allowed
@@ -324,6 +346,8 @@ def x_search_tool(
"tools": [tool_def],
"store": False,
}
+ if reasoning_effort:
+ payload["reasoning"] = {"effort": reasoning_effort}
timeout_seconds = _get_x_search_timeout_seconds()
max_retries = _get_x_search_retries()
diff --git a/tui_gateway/_stdin_recovery.py b/tui_gateway/_stdin_recovery.py
new file mode 100644
index 000000000000..80c77aeb0dca
--- /dev/null
+++ b/tui_gateway/_stdin_recovery.py
@@ -0,0 +1,151 @@
+"""Shared spurious stdin-EOF recovery for the TUI gateway entry point and slash worker.
+
+When a child process inherits fd 0 (stdin) and sets ``O_NONBLOCK``, the flag
+lands on the **shared open file description** — not just the child's descriptor.
+The gateway's next ``read()`` returns ``EAGAIN``, which CPython's buffered
+``TextIOWrapper`` converts to ``b''`` (apparent EOF), killing the gateway.
+
+This module provides:
+- :func:`diagnose_stdin_state` — forensic diagnostic (``O_NONBLOCK`` / ``SO_RCVTIMEO``)
+- :func:`handle_spurious_eof` — check whether an empty ``readline()`` is a genuine
+ peer-close or a spurious EOF, and recover if spurious.
+
+The recovery is **POSIX-only** (``fcntl``). On Windows, ``O_NONBLOCK`` on a
+shared file description is not a concern, so the guard simply reports a
+genuine EOF and lets the caller exit.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+
+try:
+ import fcntl as _fcntl
+ _HAS_FCNTL = True
+except ImportError:
+ _fcntl = None # type: ignore[assignment]
+ _HAS_FCNTL = False
+
+try:
+ import socket as _socket
+ _HAS_SOCKET = True
+except ImportError:
+ _socket = None # type: ignore[assignment]
+ _HAS_SOCKET = False
+
+import struct
+
+
+# Rate-limit: at most this many spurious-EOF recoveries per 60-second window.
+# A child aggressively flipping ``O_NONBLOCK`` on the shared fd would otherwise
+# create a tight busy-loop burning CPU. Exceeding the cap exits the process —
+# the parent (TUI / gateway) respawns it with fresh state, which is safer than
+# fighting forever.
+MAX_RECOVERIES_PER_MINUTE = 10
+
+
+def diagnose_stdin_state() -> str:
+ """Return a diagnostic string about stdin's current state.
+
+ Used for crash-log forensics when stdin iteration falls through.
+ Distinguishes genuine peer-close (flag clear) from spurious EOF
+ caused by a child setting ``O_NONBLOCK`` on the shared file description.
+ """
+ parts: list[str] = []
+ if _HAS_FCNTL and _fcntl is not None:
+ try:
+ flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
+ parts.append(f"O_NONBLOCK={'1' if flags & os.O_NONBLOCK else '0'}")
+ except Exception as e:
+ parts.append(f"F_GETFL error: {e}")
+ else:
+ parts.append("O_NONBLOCK=n/a (no fcntl)")
+ # ``SO_RCVTIMEO`` is a socket option (not a file-status flag), equally
+ # shared on the open file description. A child setting it via
+ # ``setsockopt`` launders into the same spurious-EOF path with
+ # ``O_NONBLOCK`` clear, so we report it alongside the flag.
+ if _HAS_SOCKET and _socket is not None:
+ try:
+ s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
+ try:
+ tv = s.getsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO)
+ parts.append(f"SO_RCVTIMEO={tv!r}")
+ finally:
+ # ``fromfd`` duped the fd; ``close`` releases the dup without
+ # touching the original fd 0.
+ s.close()
+ except Exception:
+ pass
+ return ", ".join(parts) if parts else "unknown"
+
+
+def handle_spurious_eof(
+ recovery_times: list[float],
+ log_fn: object,
+) -> bool:
+ """Check whether an empty ``readline()`` is spurious; recover if so.
+
+ Returns ``True`` if the caller should ``continue`` the read loop
+ (spurious EOF was recovered), ``False`` if it should ``break`` (genuine
+ peer-close or rate limit exceeded).
+
+ ``log_fn`` is called with a diagnostic string — ``_log_exit`` in
+ ``entry.py``, ``print(file=sys.stderr)`` in ``slash_worker.py``.
+ """
+ # Without ``fcntl`` (Windows) we can't check the flag, and the
+ # ``O_NONBLOCK`` shared-description issue is POSIX-specific anyway —
+ # treat it as a genuine EOF.
+ if not (_HAS_FCNTL and _fcntl is not None):
+ log_fn("stdin EOF (peer closed)") # type: ignore[operator]
+ return False
+
+ try:
+ flags = _fcntl.fcntl(0, _fcntl.F_GETFL)
+ is_nonblock = bool(flags & os.O_NONBLOCK)
+ except Exception:
+ is_nonblock = False
+
+ if not is_nonblock:
+ # Genuine peer-close — no subprocess flag tampering detected.
+ log_fn("stdin EOF (peer closed)") # type: ignore[operator]
+ return False
+
+ # Spurious EOF: a child set ``O_NONBLOCK`` (and/or ``SO_RCVTIMEO``) on
+ # the shared file description, laundered into ``b''`` / ``EAGAIN`` by
+ # CPython's buffered layer. Restore blocking mode and resume.
+ now = time.time()
+ recovery_times.append(now)
+ recovery_times[:] = [t for t in recovery_times if t > now - 60]
+ if len(recovery_times) > MAX_RECOVERIES_PER_MINUTE:
+ log_fn( # type: ignore[operator]
+ f"stdin spurious-EOF recovery rate exceeded "
+ f"({len(recovery_times)}/min, cap {MAX_RECOVERIES_PER_MINUTE})"
+ )
+ return False
+
+ diag = diagnose_stdin_state()
+ log_fn(f"stdin spurious EOF (subprocess O_NONBLOCK flip), recovering: {diag}") # type: ignore[operator]
+
+ # Clear ``O_NONBLOCK`` on the shared file description.
+ os.set_blocking(0, True)
+
+ # Also clear ``SO_RCVTIMEO`` if it was set by a child on the shared
+ # description. A non-zero timeout would cause the next ``readline()``
+ # to time out and return ``''`` again, looping until the rate limiter
+ # kicks in. Clearing it restores fully blocking semantics.
+ if _HAS_SOCKET and _socket is not None:
+ try:
+ s = _socket.fromfd(0, _socket.AF_UNIX, _socket.SOCK_STREAM)
+ try:
+ # Zero timeval: tv_sec=0, tv_usec=0 (struct timeval on most platforms)
+ s.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVTIMEO, struct.pack("ll", 0, 0))
+ finally:
+ s.close()
+ except Exception:
+ pass
+
+ # ``_io.TextIOWrapper.readline`` returns an empty string on ``EAGAIN``
+ # but does NOT stick EOF; after restoring blocking, the next call will
+ # block until data arrives or the peer truly closes.
+ return True
diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py
index bc2a42d4f735..bfcf1b7779b8 100644
--- a/tui_gateway/compute_host.py
+++ b/tui_gateway/compute_host.py
@@ -599,7 +599,7 @@ class ComputeHost:
def _rss_mb(pid: int) -> float:
try:
- out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True, stderr=subprocess.DEVNULL, timeout=2).strip()
+ out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)], text=True, stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2).strip()
return int(out.splitlines()[-1].strip()) / 1024.0 if out else 0.0
except Exception:
return 0.0
diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py
index c738330f6dc4..be755c461b5c 100644
--- a/tui_gateway/entry.py
+++ b/tui_gateway/entry.py
@@ -16,6 +16,8 @@ import signal
import time
import traceback
+from tui_gateway._stdin_recovery import handle_spurious_eof
+
from tui_gateway import server
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
from tui_gateway.transport import TeeTransport
@@ -290,6 +292,10 @@ def join_mcp_discovery(timeout: float | None = None) -> bool:
return entry_done and startup_done
+# Spurious stdin-EOF recovery tracker (shared open-file-description O_NONBLOCK flip).
+_recovery_times: list[float] = []
+
+
def main():
_install_sidecar_publisher()
@@ -354,7 +360,15 @@ def main():
_log_exit("startup write failed (broken stdout pipe before first event)")
sys.exit(0)
- for raw in sys.stdin:
+ while True:
+ raw = sys.stdin.readline()
+ if not raw:
+ # Stdin fell through — check if spurious (O_NONBLOCK flip by a
+ # child on the shared open file description) or genuine EOF.
+ if not handle_spurious_eof(_recovery_times, _log_exit):
+ break
+ continue
+
line = raw.strip()
if not line:
continue
@@ -374,8 +388,6 @@ def main():
_log_exit(f"response write failed for method={method!r} (broken stdout pipe)")
sys.exit(0)
- _log_exit("stdin EOF (TUI closed the command pipe)")
-
if __name__ == "__main__":
main()
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 4598fcad5bde..e8b0de2ff86c 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -242,6 +242,12 @@ _LONG_HANDLERS = frozenset(
# the WS read loop and causing false "needs setup" (#50005 family).
"setup.runtime_check",
"setup.status",
+ # Desktop also polls the in-memory live-session registry every 15s.
+ # The handler is normally cheap, but under heavy agent GIL pressure it
+ # can still stall for tens of seconds. Keep it off the WS reader thread
+ # so a delayed status rehydrate cannot block runtime readiness, prompt
+ # submission, or interrupts queued behind it on the same socket.
+ "session.active_list",
"session.branch",
"session.compress",
"session.list",
@@ -424,6 +430,20 @@ def _load_busy_input_mode() -> str:
return raw if raw in {"queue", "steer", "interrupt"} else "interrupt"
+def _load_interim_assistant_messages() -> bool:
+ """Return whether interim assistant commentary should be surfaced to UIs.
+
+ Honors ``display.interim_assistant_messages`` (default true). When false,
+ the tui_gateway does not install ``interim_assistant_callback``, so
+ interim text from tool-call turns and verify-on-stop candidates is never
+ emitted as ``message.interim`` — mirroring the messaging gateway's gating.
+ """
+ display = _load_cfg().get("display")
+ if not isinstance(display, dict):
+ return True
+ return is_truthy_value(display.get("interim_assistant_messages", True))
+
+
def _notify_session_boundary(
event_type: str, session_id: str | None, platform: str | None = None
) -> None:
@@ -1990,8 +2010,13 @@ def _ensure_session_db_row(session: dict) -> None:
)
if (reasoning := session.get("create_reasoning_override")) is not None:
model_config["reasoning_config"] = reasoning
- if tier := session.get("create_service_tier_override"):
- model_config["service_tier"] = tier
+ create_service_tier_override = session.get("create_service_tier_override")
+ if create_service_tier_override is not None:
+ # Empty string is the in-memory sentinel for an explicit normal tier:
+ # it bypasses _make_agent's profile fallback without sending a bogus
+ # service_tier value to the provider. Persist a durable marker so resume
+ # can distinguish that choice from an omitted/inherited tier.
+ model_config["service_tier"] = create_service_tier_override or "normal"
# Branch lineage: stamp the same ``_branched_from`` marker the TUI /branch
# uses so list_sessions_rich keeps the branch listed and the desktop sidebar
# can nest it under its parent.
@@ -2610,7 +2635,11 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict:
overrides["provider_override"] = provider
if isinstance(reasoning_config, dict):
overrides["reasoning_config_override"] = reasoning_config
- if service_tier:
+ if service_tier.lower() == "normal":
+ # None means "inherit the profile" at _make_agent. Empty string is a
+ # real override that means "do not request a priority service tier".
+ overrides["service_tier_override"] = ""
+ elif service_tier:
overrides["service_tier_override"] = service_tier
return overrides
@@ -2698,6 +2727,12 @@ def _persist_live_session_runtime(session: dict | None) -> None:
if isinstance(parsed, dict):
existing_config = parsed
model_config = _runtime_model_config(agent, existing_config)
+ create_service_tier_override = session.get("create_service_tier_override")
+ if create_service_tier_override is not None:
+ # _runtime_model_config sees agent.service_tier=None for explicit
+ # normal and would otherwise erase the distinction on every live
+ # metadata persist.
+ model_config["service_tier"] = create_service_tier_override or "normal"
model = str(getattr(agent, "model", "") or "").strip()
if hasattr(db, "update_session_meta"):
db.update_session_meta(session_key, json.dumps(model_config), model or None)
@@ -3212,6 +3247,7 @@ def _apply_model_switch(
is_global_flag,
is_session,
is_once=one_turn,
+ explicit_provider=explicit_provider,
)
)
if not model_input:
@@ -3692,7 +3728,8 @@ def _current_profile_name() -> str:
# cryptically downstream. Bump whenever the desktop's backend contract changes.
# v2: adds the file.attach RPC (remote-gateway non-image file upload).
# v3: adds approvals.mode config RPCs and session.info reconciliation.
-DESKTOP_BACKEND_CONTRACT = 3
+# v4: session.create fast=false is an explicit per-session normal-tier override.
+DESKTOP_BACKEND_CONTRACT = 4
def _session_usage_snapshot(session: dict | None) -> dict:
@@ -4290,7 +4327,7 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
def _agent_cbs(sid: str) -> dict:
- return {
+ callbacks = {
"tool_start_callback": lambda tc_id, name, args: _on_tool_start(
sid, tc_id, name, args
),
@@ -4345,6 +4382,22 @@ def _agent_cbs(sid: str) -> dict:
),
}
+ # Interim assistant commentary (text alongside tool calls, or the attempted
+ # final answer before a verify-on-stop nudge). Gated on
+ # display.interim_assistant_messages (default true). Also set per-turn in
+ # _run_prompt_submit as defense-in-depth — the per-turn set overwrites
+ # this, and the finally block clears it so a stale closure can't fire.
+ if _load_interim_assistant_messages():
+ callbacks["interim_assistant_callback"] = (
+ lambda text, *, already_streamed=False: _emit(
+ "message.interim",
+ sid,
+ {"text": str(text), "already_streamed": bool(already_streamed)},
+ )
+ )
+
+ return callbacks
+
def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
"""Intentional workspace move from the project_* tools: re-anchor the live
@@ -4754,22 +4807,22 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
def _reset_session_agent(sid: str, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
- # Preserve this session's chosen model AND reasoning across /new so a
- # reset doesn't silently revert to global config (or to a model
- # another session set). See the cross-session-contamination note in
- # _apply_model_switch.
- reset_kw = {"model_override": session.get("model_override")}
- old_reasoning = getattr(session.get("agent"), "reasoning_config", None)
- if old_reasoning is None:
- old_reasoning = session.get("create_reasoning_override")
- if isinstance(old_reasoning, dict):
- reset_kw["reasoning_config_override"] = old_reasoning
+ # /new is a full conversation boundary: session-scoped runtime
+ # overrides (/model, /reasoning, /fast) do NOT carry forward — the
+ # fresh agent re-derives model/provider, reasoning, and service tier
+ # from config.yaml (#48055, #23131). Session pins are cleared below so
+ # a rebuild can't resurrect them. (Global process state is still never
+ # touched — see the cross-session-contamination note in
+ # _apply_model_switch.)
+ session.pop("model_override", None)
+ session.pop("create_reasoning_override", None)
+ session.pop("create_service_tier_override", None)
+ session.pop("one_turn_model_restore", None)
new_agent = _make_agent(
sid,
session["session_key"],
session_id=session["session_key"],
platform_override=_session_source(session),
- **reset_kw,
)
finally:
_clear_session_context(tokens)
@@ -5765,9 +5818,14 @@ def _(rid, params: dict) -> dict:
create_reasoning_override = parse_reasoning_effort(effort)
except Exception:
create_reasoning_override = None
- # Only pin "fast" when explicitly requested; leaving it None lets the build
- # fall back to the profile default service tier rather than forcing normal.
- create_service_tier_override = "priority" if params.get("fast") else None
+ # Presence is part of the contract: omitted means inherit the profile,
+ # true pins priority, and false pins normal. Empty string is the internal
+ # explicit-normal sentinel because _make_agent uses None for inheritance.
+ create_service_tier_override = None
+ if "fast" in params:
+ create_service_tier_override = (
+ "priority" if is_truthy_value(params.get("fast")) else ""
+ )
ready = threading.Event()
now = time.time()
@@ -6293,7 +6351,7 @@ def _(rid, params: dict) -> dict:
# Display keeps the full transcript; the model-fed history drops a
# dangling/interrupted tool-call tail so a session killed mid-loop does
# not replay the unanswered call forever (#29086).
- prefix = display_history[: max(0, len(display_history) - len(raw_history))]
+ prefix = db.get_ancestor_display_prefix(target)
history = sanitize_replay_history(raw_history)
# Restore the model/provider/reasoning/tier this chat last used so the
# deferred build (and the info below) match the eager path — without them
@@ -6369,9 +6427,7 @@ def _(rid, params: dict) -> dict:
# re-issue the unanswered call forever — the permanent-"thinking" stuck
# session in #29086. The messaging gateway already strips this; this is
# the WebUI/TUI resume path picking up the same cleanup.
- display_history_prefix = display_history[
- : max(0, len(display_history) - len(raw_history))
- ]
+ display_history_prefix = db.get_ancestor_display_prefix(target)
history = sanitize_replay_history(raw_history)
messages = _history_to_messages(display_history)
tokens = _set_session_context(target)
@@ -10012,6 +10068,22 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
payload["rendered"] = r
_emit("message.delta", sid, payload)
+ # Surface interim assistant text (commentary emitted alongside
+ # tool calls, or the attempted final answer before a verify-on-stop
+ # nudge) so the desktop can seal it as its own segment instead of
+ # losing it when message.complete replaces the streaming buffer.
+ # Gated on display.interim_assistant_messages (default true).
+ if _load_interim_assistant_messages():
+ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
+ _emit("message.interim", sid, {
+ "text": text,
+ "already_streamed": already_streamed,
+ })
+
+ agent.interim_assistant_callback = _interim_assistant_cb
+ else:
+ agent.interim_assistant_callback = None
+
run_kwargs = {
"conversation_history": list(history),
"stream_callback": _stream,
@@ -10133,6 +10205,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
payload["reasoning"] = last_reasoning
if status_note:
payload["warning"] = status_note
+ if result.get("response_previewed"):
+ payload["response_previewed"] = True
rendered = render_message(raw, cols)
if rendered:
payload["rendered"] = rendered
@@ -10316,6 +10390,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
if home_token is not None:
reset_hermes_home_override(home_token)
_clear_session_context(session_tokens)
+ # Clear the per-turn interim callback so a stale closure from
+ # this turn can't fire during a later turn on the same agent.
+ agent.interim_assistant_callback = None
with session["history_lock"]:
session["running"] = False
session["last_active"] = time.time()
@@ -11536,6 +11613,8 @@ def _(rid, params: dict) -> dict:
from hermes_constants import parse_reasoning_effort
arg = str(value or "").strip().lower()
+ scope = str(params.get("scope") or "").strip().lower()
+ global_scope = scope == "global"
if arg in {"show", "on"}:
cfg = _load_cfg()
display = (
@@ -11615,23 +11694,25 @@ def _(rid, params: dict) -> dict:
parsed = parse_reasoning_effort(arg)
if parsed is None:
return _err(rid, 4002, f"unknown reasoning value: {value}")
- if session is not None:
+ if global_scope or session is None:
+ _write_config_key("agent.reasoning_effort", arg)
+ if session is not None:
+ session.pop("create_reasoning_override", None)
+ else:
# Session-scoped, like the messaging gateway's `/reasoning
# ` (global persistence is `--global` / Settings →
# Model territory). Writing config.yaml here let every
# desktop model-menu selection rewrite the user's global
# agent.reasoning_effort to the preset default.
session["create_reasoning_override"] = parsed
- if session.get("agent") is not None:
- session["agent"].reasoning_config = parsed
- _persist_live_session_runtime(session)
- _emit(
- "session.info",
- params.get("session_id", ""),
- _session_info(session["agent"], session),
- )
- else:
- _write_config_key("agent.reasoning_effort", arg)
+ if session and session.get("agent") is not None:
+ session["agent"].reasoning_config = parsed
+ _persist_live_session_runtime(session)
+ _emit(
+ "session.info",
+ params.get("session_id", ""),
+ _session_info(session["agent"], session),
+ )
return _ok(rid, {"key": key, "value": arg})
except Exception as e:
return _err(rid, 5001, str(e))
@@ -12326,19 +12407,23 @@ def _(rid, params: dict) -> dict:
)
if key == "reasoning":
cfg = _load_cfg()
- effort = ""
- # Prefer the session's live value — `config.set reasoning` is
- # session-scoped, so the global key may not reflect this chat.
session = _sessions.get(params.get("session_id", ""))
- live = getattr((session or {}).get("agent"), "reasoning_config", None)
- if live is None and session is not None:
- live = session.get("create_reasoning_override")
- if isinstance(live, dict):
- if live.get("enabled") is False:
+ reasoning_config = None
+ if session is not None:
+ if isinstance(session.get("create_reasoning_override"), dict):
+ reasoning_config = session.get("create_reasoning_override")
+ else:
+ agent = session.get("agent")
+ agent_reasoning = getattr(agent, "reasoning_config", None)
+ if isinstance(agent_reasoning, dict):
+ reasoning_config = agent_reasoning
+
+ if isinstance(reasoning_config, dict):
+ if reasoning_config.get("enabled") is False:
effort = "none"
else:
- effort = str(live.get("effort", "") or "")
- if not effort:
+ effort = str(reasoning_config.get("effort") or "medium")
+ else:
raw_effort = (cfg.get("agent") or {}).get("reasoning_effort", "")
if raw_effort is False:
# YAML `reasoning_effort: false`/`off`/`no` — thinking
diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py
index 78905a5637a4..5b75dbd16565 100644
--- a/tui_gateway/slash_worker.py
+++ b/tui_gateway/slash_worker.py
@@ -27,6 +27,7 @@ import time
import cli as cli_mod
from cli import HermesCLI
+from tui_gateway._stdin_recovery import handle_spurious_eof
from rich.console import Console
# Env-overridable so the integration test can drive sub-second timing.
@@ -140,7 +141,21 @@ def main():
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
- for raw in sys.stdin:
+ # Spurious stdin-EOF recovery (same O_NONBLOCK shared file-description
+ # issue as the gateway entry point — any child inheriting fd 0 can flip
+ # the flag and launder EAGAIN into an apparent EOF).
+ _sw_recovery_times: list[float] = []
+
+ def _sw_log(reason: str) -> None:
+ print(f"[slash-worker] {reason}", file=sys.stderr, flush=True)
+
+ while True:
+ raw = sys.stdin.readline()
+ if not raw:
+ if not handle_spurious_eof(_sw_recovery_times, _sw_log):
+ break
+ continue
+
line = raw.strip()
if not line:
continue
diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
index c84982d681aa..fcd7090b8b0b 100644
--- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
@@ -131,3 +131,68 @@ describe('flush-boundary SGR mouse reassembly', () => {
expect(key).toMatchObject({ name: 'wheelup' })
})
})
+
+describe('cursor position report parsing', () => {
+ it('parses DECXCPR cursor position report (CSI ? row;col R)', () => {
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?22;1R')
+
+ expect(key).toMatchObject({
+ kind: 'response',
+ response: { type: 'cursorPosition', row: 22, col: 1 }
+ })
+ })
+
+ it('parses standard DSR cursor position report (CSI row;col R) when row > 1', () => {
+ // Terminals that don't support DECXCPR may respond to CSI ? 6 n with
+ // the plain DSR form (no ?). These must be recognized as responses,
+ // not inserted as literal text.
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[22;1R')
+
+ expect(key).toMatchObject({
+ kind: 'response',
+ response: { type: 'cursorPosition', row: 22, col: 1 }
+ })
+ })
+
+ it('parses standard DSR report with multi-digit row and col', () => {
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[10;80R')
+
+ expect(key).toMatchObject({
+ kind: 'response',
+ response: { type: 'cursorPosition', row: 10, col: 80 }
+ })
+ })
+
+ it('does NOT treat CSI 1;2 R as a cursor position report (Shift+F3 ambiguity)', () => {
+ // CSI 1;2 R is Shift+F3 in xterm. Without the ? marker, row 1 is
+ // ambiguous with F3 modifiers — must fall through to parseKeypress,
+ // not be silently dropped as a terminal response.
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[1;2R')
+
+ expect(key.kind).not.toBe('response')
+ })
+
+ it('does NOT treat CSI 1;5 R as a cursor position report (Ctrl+F3 ambiguity)', () => {
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[1;5R')
+
+ expect(key.kind).not.toBe('response')
+ })
+
+ it('does NOT treat CSI 0;col R as a cursor position report (invalid row-zero DSR)', () => {
+ // Terminal coordinates are 1-indexed, so a plain DSR report with row 0 is
+ // invalid. Without the ? marker it must remain unclassified rather than be
+ // reported as a cursor position (guard is row <= 1, not row === 1).
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[0;5R')
+
+ expect(key.kind).not.toBe('response')
+ })
+
+ it('treats DECXCPR at row 1 as a cursor position report (? disambiguates from F3)', () => {
+ const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?1;2R')
+
+ expect(key).toMatchObject({
+ kind: 'response',
+ response: { type: 'cursorPosition', row: 1, col: 2 }
+ })
+ })
+})
diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
index 966e32bac74d..59981f543fbd 100644
--- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts
@@ -44,11 +44,14 @@ const DA2_RE = /^\x1b\[>([\d;]*)c$/
// (private ? marker distinguishes from CSI u key events)
// eslint-disable-next-line no-control-regex
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
-// DECXCPR cursor position: CSI ? row ; col R
-// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
-// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
+// Cursor position report: CSI [?] row ; col R
+// DECXCPR (CSI ? row;col R) is unambiguous and always matched.
+// Standard DSR (CSI row;col R, no ?) is also matched, but only when
+// row > 1 — modified F3 keys use CSI 1;modifier R (Shift+F3 = CSI 1;2 R,
+// Ctrl+F3 = CSI 1;5 R, etc.), and F3 always has row 1. Reports at row 1
+// without the ? marker fall through to parseKeypress to preserve F3.
// eslint-disable-next-line no-control-regex
-const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
+const CURSOR_POSITION_RE = /^\x1b\[(\??)(\d+);(\d+)R$/
// OSC response: OSC code ; data (BEL|ST)
// eslint-disable-next-line no-control-regex
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
@@ -145,10 +148,23 @@ function parseTerminalResponse(s: string): TerminalResponse | null {
}
if ((m = CURSOR_POSITION_RE.exec(s))) {
+ const hasPrivateMarker = m[1] === '?'
+ const row = parseInt(m[2]!, 10)
+
+ // Without the DEC-private '?' marker, CSI row;col R is ambiguous with
+ // modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). F3 always uses row 1,
+ // so only treat the standard form as a cursor position report when
+ // row > 1. Row <= 1 reports without '?' fall through to parseKeypress:
+ // row 1 preserves F3, and row 0 is an invalid DSR report (terminal
+ // coordinates are 1-indexed) that should remain unclassified.
+ if (!hasPrivateMarker && row <= 1) {
+ return null
+ }
+
return {
type: 'cursorPosition',
- row: parseInt(m[1]!, 10),
- col: parseInt(m[2]!, 10)
+ row,
+ col: parseInt(m[3]!, 10)
}
}
diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
index 9103877bacc1..daf8617c3091 100644
--- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
+++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
@@ -1683,4 +1683,81 @@ describe('createGatewayEventHandler', () => {
expect(openExternalUrlMock).not.toHaveBeenCalled()
})
})
+
+ describe('message.interim', () => {
+ it('finalizes an interim segment without settling the turn', () => {
+ const appended: Msg[] = []
+ const onEvent = createGatewayEventHandler(buildCtx(appended))
+
+ onEvent({ payload: {}, type: 'message.start' } as any)
+ onEvent({ payload: { text: 'streaming text' }, type: 'message.delta' } as any)
+ onEvent({ payload: { already_streamed: true, text: 'streaming text' }, type: 'message.interim' } as any)
+
+ // Turn is still active — busy stays true, no completion messages appended
+ expect(getUiState().busy).toBe(true)
+ expect(appended).toHaveLength(0)
+ })
+
+ it('keeps identical interim and terminal replies as separate messages without response_previewed', () => {
+ const appended: Msg[] = []
+ const onEvent = createGatewayEventHandler(buildCtx(appended))
+
+ onEvent({ payload: {}, type: 'message.start' } as any)
+ onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)
+ onEvent({ payload: { text: 'same reply' }, type: 'message.complete' } as any)
+
+ const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)
+ expect(assistantMsgs).toHaveLength(2)
+ })
+
+ it('settles identical terminal reply onto interim when response_previewed', () => {
+ const appended: Msg[] = []
+ const onEvent = createGatewayEventHandler(buildCtx(appended))
+
+ onEvent({ payload: {}, type: 'message.start' } as any)
+ onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)
+ onEvent({ payload: { response_previewed: true, text: 'same reply' }, type: 'message.complete' } as any)
+
+ // With response_previewed, the terminal reply is the same model
+ // response that was published provisionally — settle onto the
+ // interim instead of duplicating. (#65919 review)
+ const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)
+ expect(assistantMsgs).toHaveLength(1)
+ expect(assistantMsgs[0]?.text).toBe('same reply')
+ })
+
+ it('deduplicates flushed chunks within the terminal message after an interim boundary', () => {
+ const appended: Msg[] = []
+ const onEvent = createGatewayEventHandler(buildCtx(appended))
+
+ onEvent({ payload: {}, type: 'message.start' } as any)
+ // Interim seals the first segment
+ onEvent({ payload: { already_streamed: true, text: 'interim answer' }, type: 'message.interim' } as any)
+ // Post-interim deltas that match the final text — these get deduped
+ onEvent({ payload: { text: 'final answer' }, type: 'message.delta' } as any)
+ onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
+
+ const texts = appended.filter(m => m.role === 'assistant' && m.text).map(m => m.text)
+ // interim + final, no duplication of the final
+ expect(texts).toContain('interim answer')
+ expect(texts.filter(t => t === 'final answer')).toHaveLength(1)
+ })
+
+ it('ignores malformed message.interim payload', () => {
+ const appended: Msg[] = []
+ const onEvent = createGatewayEventHandler(buildCtx(appended))
+
+ onEvent({ payload: {}, type: 'message.start' } as any)
+ // No payload at all
+ onEvent({ type: 'message.interim' } as any)
+ // Empty text
+ onEvent({ payload: { text: '' }, type: 'message.interim' } as any)
+ // Undefined text
+ onEvent({ payload: { text: undefined }, type: 'message.interim' } as any)
+
+ // Turn continues without finalizing or throwing
+ expect(getUiState().busy).toBe(true)
+ expect(appended).toHaveLength(0)
+ })
+ })
})
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index b4dc5f7b6761..7096798ecf93 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -267,6 +267,55 @@ describe('createSlashHandler', () => {
})
})
+ it('reads /reasoning status for the active session', () => {
+ patchUiState({ sid: 'sid-abc' })
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/reasoning')).toBe(true)
+ expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', {
+ key: 'reasoning',
+ session_id: 'sid-abc'
+ })
+ })
+
+ it.each(['low', 'max', 'ultra'])('sends plain /reasoning %s without a scope (session default)', effort => {
+ patchUiState({ sid: 'sid-abc' })
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true)
+ expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
+ key: 'reasoning',
+ session_id: 'sid-abc',
+ value: effort
+ })
+ })
+
+ it('sends /reasoning --global as global config.set', () => {
+ patchUiState({ sid: 'sid-abc' })
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/reasoning high --global')).toBe(true)
+ expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
+ key: 'reasoning',
+ scope: 'global',
+ session_id: 'sid-abc',
+ value: 'high'
+ })
+ })
+
+ it('strips /reasoning session flags before config.set', () => {
+ patchUiState({ sid: 'sid-abc' })
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/reasoning low --session')).toBe(true)
+ expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
+ key: 'reasoning',
+ scope: 'session',
+ session_id: 'sid-abc',
+ value: 'low'
+ })
+ })
+
it('opens the skills hub locally for bare /skills', () => {
const ctx = buildCtx()
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index 051f09347777..f1c87665f286 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -946,6 +946,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
turnController.recordMessageDelta(ev.payload ?? {})
return
+ case 'message.interim': {
+ const text = ev.payload?.text
+
+ if (typeof text === 'string' && text.trim()) {
+ turnController.recordInterimMessage(text)
+ }
+
+ return
+ }
+
case 'message.complete': {
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts
index 47922c5f7add..e2092a14b745 100644
--- a/ui-tui/src/app/slash/commands/session.ts
+++ b/ui-tui/src/app/slash/commands/session.ts
@@ -23,6 +23,8 @@ import type { SlashCommand } from '../types.js'
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
+const REASONING_SESSION_FLAGS = new Set(['--session'])
+const REASONING_GLOBAL_FLAGS = new Set(['--global'])
const modelValueForConfigSet = (arg: string) => {
const trimmed = arg.trim()
@@ -38,6 +40,42 @@ const modelValueForConfigSet = (arg: string) => {
return trimmed
}
+const reasoningConfigPayload = (arg: string, sid: string) => {
+ const parts = arg.trim().split(/\s+/).filter(Boolean)
+ let scope = ''
+ const valueParts: string[] = []
+
+ for (const part of parts) {
+ const flag = part.toLowerCase()
+
+ if (REASONING_GLOBAL_FLAGS.has(flag)) {
+ scope = 'global'
+
+ continue
+ }
+
+ if (REASONING_SESSION_FLAGS.has(flag)) {
+ // Session scope is the default; accept the flag for parity with /model.
+ if (!scope) {
+ scope = 'session'
+ }
+
+ continue
+ }
+
+ valueParts.push(part)
+ }
+
+ const value = valueParts.join(' ')
+
+ return {
+ key: 'reasoning',
+ session_id: sid,
+ value,
+ ...(scope ? { scope } : {})
+ }
+}
+
export const sessionCommands: SlashCommand[] = [
{
aliases: ['bg', 'btw'],
@@ -445,7 +483,7 @@ export const sessionCommands: SlashCommand[] = [
run: (arg, ctx) => {
if (!arg) {
return ctx.gateway
- .rpc('config.get', { key: 'reasoning' })
+ .rpc('config.get', { key: 'reasoning', session_id: ctx.sid })
.then(
ctx.guarded(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
@@ -453,7 +491,7 @@ export const sessionCommands: SlashCommand[] = [
)
}
- ctx.gateway.rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }).then(
+ ctx.gateway.rpc('config.set', reasoningConfigPayload(arg, ctx.sid ?? '')).then(
ctx.guarded(r => {
if (!r.value) {
return
diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts
index a15810d0dbd6..a8afea727d99 100644
--- a/ui-tui/src/app/turnController.ts
+++ b/ui-tui/src/app/turnController.ts
@@ -126,6 +126,7 @@ class TurnController {
private activeTools: ActiveTool[] = []
private activeReasoningText = ''
private reasoningSegmentIndex: null | number = null
+ private interimBoundaryIndex: null | number = null
private activityId = 0
private reasoningStreamingTimer: Timer = null
private reasoningTimer: Timer = null
@@ -554,7 +555,12 @@ class TurnController {
this.flushPendingNotice()
}
- recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
+ recordMessageComplete(payload: {
+ rendered?: string
+ reasoning?: string
+ response_previewed?: boolean
+ text?: string
+ }) {
this.closeReasoningSegment()
// Ink renders markdown via ; the gateway's Rich-rendered ANSI
@@ -565,7 +571,15 @@ class TurnController {
// only when the gateway elected not to send any (#16391).
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
- const finalText = finalTail(split.text, this.segmentMessages)
+ // Only dedupe segments AFTER the interim boundary — interim-sealed
+ // segments are preserved even if the final text includes them.
+ // Exception: when response_previewed is true, the final text is the
+ // same model response that was published provisionally as an interim
+ // message. Dedupe against ALL segments (including sealed interims) so
+ // the identical text doesn't render as a duplicate message. (#65919
+ // review: duplicate-message blocker)
+ const dedupeStart = payload.response_previewed ? 0 : (this.interimBoundaryIndex ?? 0)
+ const finalText = finalTail(split.text, this.segmentMessages.slice(dedupeStart))
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedToolTokens = this.toolTokenAcc
@@ -672,6 +686,32 @@ class TurnController {
}
}
+ recordInterimMessage(text: string) {
+ if (this.interrupted) {
+ return
+ }
+
+ const authoritativeText = text.trimStart()
+
+ if (!authoritativeText) {
+ return
+ }
+
+ // If the streaming buffer hasn't caught up to the authoritative interim
+ // text (e.g. the backend didn't stream every token), sync it so the
+ // sealed segment matches what the user should see.
+ if (this.bufRef.trimStart() !== authoritativeText) {
+ this.bufRef = authoritativeText
+ }
+
+ // Flush the current streaming buffer into a sealed segment — this is the
+ // TUI equivalent of the desktop's finalizeInterimAssistantMessage. The
+ // segment survives message.complete's finalTail dedupe because
+ // interimBoundaryIndex marks it as interim-sealed.
+ this.flushStreamingSegment()
+ this.interimBoundaryIndex = this.segmentMessages.length
+ }
+
recordReasoningAvailable(text: string, force = false) {
if (this.interrupted || (!force && !getUiState().showReasoning)) {
return
@@ -885,6 +925,7 @@ class TurnController {
this.pendingSegmentTools = []
this.protocolWarned = false
this.reasoningSegmentIndex = null
+ this.interimBoundaryIndex = null
this.segmentMessages = []
this.turnTools = []
this.toolTokenAcc = 0
@@ -941,6 +982,7 @@ class TurnController {
this.activeTools = []
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
+ this.interimBoundaryIndex = null
this.turnTools = []
this.toolTokenAcc = 0
this.interrupted = false
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index 953b8a812579..7fa2c34b37ae 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -641,7 +641,12 @@ export type GatewayEvent =
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
| {
- payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
+ payload: { already_streamed?: boolean; text: string }
+ session_id?: string
+ type: 'message.interim'
+ }
+ | {
+ payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage }
session_id?: string
type: 'message.complete'
}
diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx
index b05e389bdb40..e73c959e6f8e 100644
--- a/web/src/components/ModelPickerDialog.tsx
+++ b/web/src/components/ModelPickerDialog.tsx
@@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { fuzzyRank } from "@/lib/fuzzy";
+import { queryMatchesProviderOnly } from "@/lib/model-picker-filter";
/**
* Two-stage model picker modal.
@@ -226,15 +227,30 @@ export function ModelPickerDialog(props: Props) {
[providers, trimmedQuery],
);
+ // A query that matched the SELECTED provider by name/slug (not its models)
+ // located that provider — it shouldn't also hide that provider's models
+ // just because their ids don't share a substring with the provider name
+ // (e.g. typing "aws" to find "AWS Build" then finding zero of its Claude
+ // model ids contain "aws"). Fall back to an unfiltered model list in that
+ // case; a query that also matches a model id keeps filtering normally.
+ const queryMatchesSelectedProviderOnly = useMemo(
+ () => queryMatchesProviderOnly(selectedProvider, models, trimmedQuery),
+ [trimmedQuery, selectedProvider, models],
+ );
+
// Fuzzy-ranked models carrying the matched character positions so the model
// list can highlight why each entry matched.
const filteredModels = useMemo(
() =>
- fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({
+ fuzzyRank(
+ models,
+ queryMatchesSelectedProviderOnly ? "" : trimmedQuery,
+ (m) => m,
+ ).map((r) => ({
model: r.item,
positions: r.positions,
})),
- [models, trimmedQuery],
+ [models, trimmedQuery, queryMatchesSelectedProviderOnly],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
diff --git a/web/src/lib/dashboard-modal-shell.test.ts b/web/src/lib/dashboard-modal-shell.test.ts
new file mode 100644
index 000000000000..2f358ebd21d1
--- /dev/null
+++ b/web/src/lib/dashboard-modal-shell.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import {
+ DASHBOARD_MODAL_BACKDROP,
+ DASHBOARD_MODAL_PANEL,
+ shouldCloseOuterModalOnEscape,
+} from "./dashboard-modal-shell";
+
+describe("dashboard modal shell", () => {
+ it("uses an opaque panel (bg-card), not the glass Card default", () => {
+ expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/);
+ expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/);
+ });
+
+ it("keeps the backdrop above page chrome (z-[100])", () => {
+ expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/);
+ expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/);
+ });
+
+ it("does not close the outer modal on Escape while a nested picker is open", () => {
+ expect(shouldCloseOuterModalOnEscape(true)).toBe(false);
+ expect(shouldCloseOuterModalOnEscape(false)).toBe(true);
+ });
+});
diff --git a/web/src/lib/dashboard-modal-shell.ts b/web/src/lib/dashboard-modal-shell.ts
new file mode 100644
index 000000000000..64cabbd84cae
--- /dev/null
+++ b/web/src/lib/dashboard-modal-shell.ts
@@ -0,0 +1,29 @@
+/**
+ * Shared dashboard dialog shell classes.
+ *
+ * Page `` defaults to `bg-background-base/80` (glass). That looks fine
+ * on the page canvas, but as a modal panel it lets the Models page bleed
+ * through and kills readability — especially on Cyberpunk / mobile.
+ *
+ * Modal panels must use opaque `bg-card`; backdrops use the same z-index
+ * band as Auxiliary / Confirm dialogs so they sit above page chrome.
+ *
+ * Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot
+ * escape the dashboard column's `relative z-2` stacking context (see
+ * ModelPickerDialog / ToolsetConfigDrawer).
+ */
+export const DASHBOARD_MODAL_BACKDROP =
+ "fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4";
+
+export const DASHBOARD_MODAL_PANEL =
+ "relative w-full border border-border bg-card shadow-2xl";
+
+/**
+ * Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog)
+ * must ignore Escape while the picker is open; the picker owns that key.
+ */
+export function shouldCloseOuterModalOnEscape(
+ nestedPickerOpen: boolean,
+): boolean {
+ return !nestedPickerOpen;
+}
diff --git a/web/src/lib/model-picker-filter.test.ts b/web/src/lib/model-picker-filter.test.ts
new file mode 100644
index 000000000000..4163d0782487
--- /dev/null
+++ b/web/src/lib/model-picker-filter.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { queryMatchesProviderOnly } from "./model-picker-filter";
+
+describe("queryMatchesProviderOnly", () => {
+ it("returns true when the query finds the provider but no model id (issue #65374)", () => {
+ // Reproduces the exact case from the issue: typing "aws" locates the
+ // "AWS Build" provider, but none of its Claude model ids contain "aws".
+ const provider = { name: "AWS Build", slug: "aws-build" };
+ const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
+
+ expect(queryMatchesProviderOnly(provider, models, "aws")).toBe(true);
+ });
+
+ it("returns false when the query also matches a model id — keeps normal filtering", () => {
+ const provider = { name: "AWS Build", slug: "aws-build" };
+ const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
+
+ expect(queryMatchesProviderOnly(provider, models, "sonnet")).toBe(false);
+ });
+
+ it("returns false when the query does not match the provider at all", () => {
+ const provider = { name: "AWS Build", slug: "aws-build" };
+ const models = ["claude-sonnet-4.5"];
+
+ expect(queryMatchesProviderOnly(provider, models, "openrouter")).toBe(false);
+ });
+
+ it("returns false for an empty query", () => {
+ const provider = { name: "AWS Build", slug: "aws-build" };
+ const models = ["claude-sonnet-4.5"];
+
+ expect(queryMatchesProviderOnly(provider, models, "")).toBe(false);
+ });
+
+ it("returns false when there is no selected provider", () => {
+ expect(queryMatchesProviderOnly(null, ["claude-sonnet-4.5"], "aws")).toBe(false);
+ });
+});
diff --git a/web/src/lib/model-picker-filter.ts b/web/src/lib/model-picker-filter.ts
new file mode 100644
index 000000000000..6d173f8e694f
--- /dev/null
+++ b/web/src/lib/model-picker-filter.ts
@@ -0,0 +1,23 @@
+import { fuzzyScoreMulti } from "@/lib/fuzzy";
+
+/**
+ * True when `trimmedQuery` located the selected provider by name/slug but
+ * matches none of its models by id — the case where a single search box
+ * filtering both the provider and model columns would otherwise leave the
+ * model pane empty even though the user just successfully found the
+ * provider they were looking for.
+ */
+export function queryMatchesProviderOnly(
+ selectedProvider: { name: string; slug: string } | null,
+ models: readonly string[],
+ trimmedQuery: string,
+): boolean {
+ if (!trimmedQuery || !selectedProvider) return false;
+
+ const matchesProvider =
+ fuzzyScoreMulti(`${selectedProvider.name} ${selectedProvider.slug}`, trimmedQuery) !=
+ null;
+ const matchesAnyModel = models.some((m) => fuzzyScoreMulti(m, trimmedQuery) != null);
+
+ return matchesProvider && !matchesAnyModel;
+}
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx
index 504c8ddb54ee..e4fb338bc970 100644
--- a/web/src/pages/ModelsPage.tsx
+++ b/web/src/pages/ModelsPage.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
+import { createPortal } from "react-dom";
import {
Brain,
ChevronDown,
@@ -22,6 +23,11 @@ import type {
ModelsAnalyticsResponse,
} from "@/lib/api";
import { timeAgo, cn, themedBody } from "@/lib/utils";
+import {
+ DASHBOARD_MODAL_BACKDROP,
+ DASHBOARD_MODAL_PANEL,
+ shouldCloseOuterModalOnEscape,
+} from "@/lib/dashboard-modal-shell";
import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@@ -711,6 +717,17 @@ function MoaModelsModal({
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ // Nested ModelPickerDialog owns Escape while open — don't dismiss MoA too.
+ const closeMoaUnlessPickerOpen = useCallback(() => {
+ if (!shouldCloseOuterModalOnEscape(picker !== null)) return;
+ onClose();
+ }, [picker, onClose]);
+
+ const modalRef = useModalBehavior({
+ open: true,
+ onClose: closeMoaUnlessPickerOpen,
+ });
+
const presetNames = Object.keys(draft.presets || {});
const preset = draft.presets[selected] || draft.presets[presetNames[0]];
const slotLabel = (slot: MoaModelSlot) => `${slot.provider || "(provider)"} · ${slot.model || "(model)"}`;
@@ -778,13 +795,36 @@ function MoaModelsModal({
if (!preset) return null;
- return (
-
-
-
- Configure Mixture of Agents presets
-
-
+ // Portal to document.body: the main dashboard column is `relative z-2`,
+ // which traps fixed descendants below the sidebar (same as ModelPickerDialog).
+ return createPortal(
+
{
+ if (e.target === e.currentTarget) closeMoaUnlessPickerOpen();
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="moa-modal-title"
+ >
+ {/* Opaque panel — do not use here; Card defaults to bg-background-base/80. */}
+
+
+
+ Configure Mixture of Agents presets
+
+
+
Presets appear as models under the Mixture of Agents provider. References produce perspectives; the aggregator is the acting model that answers and calls tools.
@@ -835,10 +875,10 @@ function MoaModelsModal({
{error &&
{error}
}
-
+
-
-
+
+
{picker && (
setPicker(null)}
/>
)}
-
+
,
+ document.body,
);
}
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index def7e87799d7..c8d570898fe5 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -199,7 +199,7 @@ Switch between already-configured models without leaving a session:
/model openrouter:anthropic/claude-sonnet-4 # Switch back to cloud
```
-By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml`:
+By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml` (or set `model.persist_switch_by_default: true` to make every switch persist):
```
/model claude-sonnet-4 --global # Switch and save as new default
@@ -209,7 +209,7 @@ By default, `/model` changes apply **to the current session only**. Add `--globa
If you've only configured OpenRouter, `/model` will only show OpenRouter models. To add another provider (Anthropic, DeepSeek, Copilot, etc.), exit your session and run `hermes model` from the terminal.
:::
-Provider and base URL changes are persisted to `config.yaml` automatically. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.
+On a `--global` switch, provider and base URL changes are persisted to `config.yaml` alongside the model. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.
## `hermes gateway`
diff --git a/website/docs/reference/model-catalog.md b/website/docs/reference/model-catalog.md
index a85163292643..4769a720c8f1 100644
--- a/website/docs/reference/model-catalog.md
+++ b/website/docs/reference/model-catalog.md
@@ -91,6 +91,20 @@ model_catalog:
The overriding manifest only needs to populate the provider block(s) it cares about. Other providers continue to resolve against the master URL.
+### Hiding providers from the picker
+
+`excluded_providers` lets you hide specific providers from the `/model` picker even when valid credentials exist. Useful when credentials are present for legacy or testing providers that shouldn't appear in normal use (e.g. an old Copilot or OpenRouter token still cached in `auth.json` or discovered via the `gh` CLI).
+
+```yaml
+model_catalog:
+ excluded_providers:
+ - copilot
+ - openrouter
+ - openai
+```
+
+The exclusion is matched case-insensitively against every key a provider can surface under — the Hermes id and models.dev id (built-in mapped providers), the overlay pid and resolved Hermes slug (overlay providers), and the canonical slug (canonical providers) — so a single entry like `copilot` hides the provider regardless of which section emits it. It is honored by every `/model` picker surface: the gateway interactive/text pickers, the TUI picker, and the interactive `hermes model` CLI picker. An empty list (or omitting the key) has no effect.
+
## Updating the manifest
Maintainers:
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index 5297a39f11d3..e04e1eb30e26 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -1323,13 +1323,17 @@ native Anthropic provider already controls effort directly and is unaffected.
You can also change the reasoning effort at runtime with the `/reasoning` command:
```
-/reasoning # Show current effort level and display state
-/reasoning high # Set reasoning effort to high
-/reasoning none # Disable reasoning
-/reasoning show # Show model thinking above each response
-/reasoning hide # Hide model thinking
+/reasoning # Show current effort level and display state
+/reasoning high # Set reasoning effort to high (this session only)
+/reasoning high --global # Set effort and persist to config.yaml
+/reasoning none # Disable reasoning (this session only)
+/reasoning show # Show model thinking above each response
+/reasoning hide # Hide model thinking
```
+Effort changes are session-scoped by default; add `--global` to save the
+new level as your `agent.reasoning_effort` default.
+
#### Per-Model Reasoning Overrides
You can set different reasoning effort levels for different models. This is useful when you want high reasoning for complex models but medium for faster ones:
diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md
index e27887461351..36dddd819d4c 100644
--- a/website/docs/user-guide/features/delegation.md
+++ b/website/docs/user-guide/features/delegation.md
@@ -206,6 +206,22 @@ The TUI ships a `/agents` overlay (alias `/tasks`) that turns recursive `delegat
The classic CLI just prints `/agents` as a text summary; the TUI is where the overlay shines. See [TUI — Slash commands](/user-guide/tui#slash-commands).
+## Live Transcripts
+
+Every `delegate_task` dispatch also creates one **append-only, human-readable log per task** so you (or the parent agent) can watch a subagent work in real time instead of waiting for the consolidated summary:
+
+```
+/cache/delegation/live//task-.log
+```
+
+The dispatch response includes the paths as `live_transcripts`, and the files are pre-created at dispatch time, so this works immediately:
+
+```bash
+tail -f ~/.hermes/cache/delegation/live/deleg_ab12cd34/task-0.log
+```
+
+Each line is timestamped and shows the child's assistant text, thinking snippets, tool calls (`-> tool_name({args})`), tool results, and a final status marker. A `manifest.json` in the same directory describes the batch (goals, task count, per-task status). The logs persist after completion — they double as the full-fidelity operational record alongside the summary — and directories older than 7 days are pruned automatically on new dispatches. Because they live under `cache/delegation`, they are also readable from remote terminal backends (Docker/Modal/SSH).
+
## Depth Limit and Nested Orchestration
By default, delegation is **flat**: a parent (depth 0) spawns children (depth 1), and those children cannot delegate further. This prevents runaway recursive delegation.
diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md
index 1c6ebf7c43f3..0a6aef6c4154 100644
--- a/website/docs/user-guide/features/memory-providers.md
+++ b/website/docs/user-guide/features/memory-providers.md
@@ -537,9 +537,9 @@ Semantic long-term memory with profile recall, semantic search, explicit memory
| | |
|---|---|
| **Best for** | Semantic recall with user profiling and session-level graph building |
-| **Requires** | `pip install supermemory` + [API key](http://app.supermemory.ai/integrations?connect=hermes) |
-| **Data storage** | Supermemory Cloud |
-| **Cost** | Supermemory pricing |
+| **Requires** | `pip install supermemory` + [cloud API key](http://app.supermemory.ai/integrations?connect=hermes), or a [self-hosted server](https://supermemory.ai/docs/self-hosting/overview) |
+| **Data storage** | Supermemory Cloud or self-hosted |
+| **Cost** | Supermemory pricing (cloud) / free (self-hosted) |
**Tools:** `supermemory_store` (save explicit memories), `supermemory_search` (semantic similarity search), `supermemory_forget` (forget by ID or best-match query), `supermemory_profile` (persistent profile + recent context)
@@ -551,10 +551,30 @@ hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
```
+Self-hosted setup:
+
+```bash
+npx supermemory local
+```
+
+Before running `hermes memory setup`, set `base_url` in
+`$HERMES_HOME/supermemory.json`:
+
+```json
+{
+ "base_url": "http://localhost:6767"
+}
+```
+
+Then run `hermes memory setup` and enter the API key printed by the local
+server. Configuring the endpoint first ensures the setup connection probe also
+stays local.
+
**Config:** `$HERMES_HOME/supermemory.json`
| Key | Default | Description |
|-----|---------|-------------|
+| `base_url` | `https://api.supermemory.ai` | API endpoint for hosted or self-hosted Supermemory. Takes priority over `SUPERMEMORY_BASE_URL`. |
| `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags. |
| `auto_recall` | `true` | Inject relevant memory context before turns |
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
@@ -564,12 +584,15 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
| `search_mode` | `hybrid` | Search mode: `hybrid`, `memories`, or `documents` |
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
-**Environment variables:** `SUPERMEMORY_API_KEY` (required), `SUPERMEMORY_CONTAINER_TAG` (overrides config).
+**Environment variables:** `SUPERMEMORY_API_KEY` (required), `SUPERMEMORY_BASE_URL` (compatibility fallback when `base_url` is not configured), `SUPERMEMORY_CONTAINER_TAG` (overrides config).
+
+Base URL precedence is `supermemory.json` → `SUPERMEMORY_BASE_URL` → `https://api.supermemory.ai`. SDK operations, setup/status probes, and conversation ingest all use the resolved endpoint.
**Key features:**
- Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution
- Full-session ingest — the entire conversation is sent once at session boundaries
- Session-end conversation ingest (to `/v4/conversations`) for richer profile + graph building in Supermemory
+- End-to-end self-hosted routing — SDK, probe, and conversation-ingest requests use the same configured endpoint
- Profile facts injected on first turn and at configurable intervals
- **Profile-scoped containers** — use `{identity}` in `container_tag` (e.g. `hermes-{identity}` → `hermes-coder`) to isolate memories per Hermes profile
- **Multi-container mode** — enable `enable_custom_container_tags` with a `custom_containers` list to let the agent read/write across named containers. Automatic operations stay on the primary container.
@@ -624,7 +647,7 @@ hermes memory setup
| **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring |
| **RetainDB** | Cloud | $20/mo | 5 | `requests` | Delta compression |
| **ByteRover** | Local/Cloud | Free/Paid | 3 | `brv` CLI | Pre-compression extraction |
-| **Supermemory** | Cloud | Paid | 4 | `supermemory` | Context fencing + session graph ingest + multi-container |
+| **Supermemory** | Cloud/Self-hosted | Free/Paid | 4 | `supermemory` | Context fencing + session graph ingest + multi-container |
| **Memori** | Cloud | Free/Paid | 5 | `hermes-memori` | Tool-aware memory + structured recall |
## Profile Isolation
diff --git a/website/docs/user-guide/features/x-search.md b/website/docs/user-guide/features/x-search.md
index 2e2004cabe02..bdfeeeba095a 100644
--- a/website/docs/user-guide/features/x-search.md
+++ b/website/docs/user-guide/features/x-search.md
@@ -50,9 +50,14 @@ Either choice satisfies the gating. You can pick whichever credentials you alrea
# ~/.hermes/config.yaml
x_search:
# xAI model used for the Responses call.
- # grok-4.20-reasoning is the recommended default; any Grok model
+ # grok-4.5 is the recommended default; any Grok model
# with x_search tool access works.
- model: grok-4.20-reasoning
+ model: grok-4.5
+
+ # Optional reasoning effort: low, medium, high, or xhigh. When omitted,
+ # the selected model's default applies. xhigh is supported only by
+ # models that document it, such as grok-4.20-multi-agent.
+ # reasoning_effort: low
# Request timeout in seconds. x_search can take 60–120s for
# complex queries — the default is generous. Minimum: 30.
@@ -63,6 +68,10 @@ x_search:
retries: 2
```
+`reasoning_effort` is sent to the xAI Responses API as
+`reasoning: {effort: ...}`. Leave it unset for models that do not support
+configurable reasoning. Invalid values fail before an API request is made.
+
## Tool parameters
The agent calls `x_search` with these arguments:
@@ -118,7 +127,7 @@ The tool surfaces this when both auth paths fail. Either set `XAI_API_KEY` in `~
### "`x_search` is not enabled for this model"
-The configured `x_search.model` doesn't have access to the server-side `x_search` tool. Switch to `grok-4.20-reasoning` (the default) or another Grok model that supports it. Check the [xAI documentation](https://docs.x.ai/) for the current list.
+The configured `x_search.model` doesn't have access to the server-side `x_search` tool. Switch to `grok-4.5` (the default) or another Grok model that supports it. Check the [xAI documentation](https://docs.x.ai/) for the current list.
### Tool doesn't appear in the schema
diff --git a/website/docs/user-guide/messaging/dingtalk.md b/website/docs/user-guide/messaging/dingtalk.md
index ce0d16a4f512..72ec3f2eadc0 100644
--- a/website/docs/user-guide/messaging/dingtalk.md
+++ b/website/docs/user-guide/messaging/dingtalk.md
@@ -154,7 +154,7 @@ gateway:
- `group_sessions_per_user: true` keeps each participant's context isolated inside shared group chats
- `require_mention: true` prevents the bot from responding to every group message — it only answers when someone @-mentions it
-- `allowed_users` under `dingtalk.extra` is an alternative to `DINGTALK_ALLOWED_USERS`; if both are set, they're merged
+- `allowed_users` under `dingtalk.extra` is an alternative to `DINGTALK_ALLOWED_USERS`; set one or the other (if both are set, only users present in both lists are authorized)
### Start the Gateway
diff --git a/website/docs/user-guide/messaging/whatsapp-cloud.md b/website/docs/user-guide/messaging/whatsapp-cloud.md
index 34cc457fca84..d3baf5d2ca30 100644
--- a/website/docs/user-guide/messaging/whatsapp-cloud.md
+++ b/website/docs/user-guide/messaging/whatsapp-cloud.md
@@ -116,7 +116,7 @@ cloudflared tunnel --url http://localhost:8090
Note the printed URL — that's what you'll give Meta.
:::warning Quick tunnels rotate
-The free quick-tunnel URL changes every time you restart `cloudflared`. For a stable URL, log in with `cloudflared tunnel login` and create a named tunnel. Free Cloudflare accounts get unlimited named tunnels — see [Cloudflare's docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) for the named-tunnel workflow.
+The free quick-tunnel URL changes every time you restart `cloudflared`. For a stable URL, log in with `cloudflared tunnel login` and create a named tunnel. Free Cloudflare accounts get unlimited named tunnels — see [Cloudflare's docs](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/) for the named-tunnel workflow.
:::
### ngrok
diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md
index 91a56b164b7a..f6dcfdf81895 100644
--- a/website/docs/user-guide/tui.md
+++ b/website/docs/user-guide/tui.md
@@ -134,9 +134,9 @@ Open it with any of these:
- `/sessions new` to create a fresh live session immediately.
- Click the `N live sessions` count in the status line.
-
+
-
+
Inside the switcher:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md
index e5016f2e1fee..2d7f762ff5cd 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md
@@ -467,9 +467,9 @@ hermes config set memory.provider byterover
| | |
|---|---|
| **适合场景** | 带用户 profile 和会话级图谱构建的语义召回 |
-| **依赖** | `pip install supermemory` + [API key](http://app.supermemory.ai/integrations?connect=hermes) |
-| **数据存储** | Supermemory Cloud |
-| **费用** | Supermemory 定价 |
+| **依赖** | `pip install supermemory` + [云端 API key](http://app.supermemory.ai/integrations?connect=hermes),或[自托管服务器](https://supermemory.ai/docs/self-hosting/overview) |
+| **数据存储** | Supermemory 云端或自托管 |
+| **费用** | 云端按 Supermemory 定价 / 自托管免费 |
**工具:** `supermemory_store`(保存显式记忆)、`supermemory_search`(语义相似度搜索)、`supermemory_forget`(按 ID 或最佳匹配查询遗忘)、`supermemory_profile`(持久化 profile + 近期上下文)
@@ -481,10 +481,28 @@ hermes config set memory.provider supermemory
echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
```
+自托管安装:
+
+```bash
+npx supermemory local
+```
+
+在运行 `hermes memory setup` **之前**,先在
+`$HERMES_HOME/supermemory.json` 中设置 `base_url`:
+
+```json
+{
+ "base_url": "http://localhost:6767"
+}
+```
+
+然后运行 `hermes memory setup` 并输入本地服务器打印的 API key。先配置端点可确保安装连接探测也只访问本地服务器。
+
**配置:** `$HERMES_HOME/supermemory.json`
| 键 | 默认值 | 描述 |
|-----|---------|-------------|
+| `base_url` | `https://api.supermemory.ai` | 托管或自托管 Supermemory 的 API 端点。优先级高于 `SUPERMEMORY_BASE_URL`。 |
| `container_tag` | `hermes` | 用于搜索和写入的容器标签。支持 `{identity}` 模板用于 profile 范围隔离。 |
| `auto_recall` | `true` | 在每轮对话前注入相关记忆上下文 |
| `auto_capture` | `true` | 每次响应后存储清理过的用户-助手轮次 |
@@ -494,12 +512,15 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
| `search_mode` | `hybrid` | 搜索模式:`hybrid`、`memories` 或 `documents` |
| `api_timeout` | `5.0` | SDK 和导入请求的超时时间 |
-**环境变量:** `SUPERMEMORY_API_KEY`(必填)、`SUPERMEMORY_CONTAINER_TAG`(覆盖配置)。
+**环境变量:** `SUPERMEMORY_API_KEY`(必填)、`SUPERMEMORY_BASE_URL`(未配置 `base_url` 时的兼容回退)、`SUPERMEMORY_CONTAINER_TAG`(覆盖配置)。
+
+Base URL 优先级为 `supermemory.json` → `SUPERMEMORY_BASE_URL` → `https://api.supermemory.ai`。SDK 操作、安装/状态探测和会话导入都会使用解析后的同一端点。
**主要特性:**
- 自动上下文隔离——从捕获的轮次中剥离已召回的记忆,防止递归记忆污染
- 在会话边界时将整个会话**一次性导入**
- 会话结束时同时导入到对话端点(`/v4/conversations`),用于 Supermemory 的 profile 和图谱构建
+- 端到端自托管路由——SDK、探测和会话导入请求使用同一配置端点
- 在第一轮及可配置间隔注入 profile 事实
- **Profile 范围容器**——在 `container_tag` 中使用 `{identity}`(例如 `hermes-{identity}` → `hermes-coder`),按 Hermes profile 隔离记忆
- **多容器模式**——启用 `enable_custom_container_tags` 并配置 `custom_containers` 列表,让 Agent 跨命名容器读写。自动操作(同步、预取)保持在主容器上。
@@ -533,7 +554,7 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
| **Holographic** | 本地 | 免费 | 2 | 无 | HRR 代数 + 信任评分 |
| **RetainDB** | 云端 | $20/月 | 5 | `requests` | 增量压缩 |
| **ByteRover** | 本地/云端 | 免费/付费 | 3 | `brv` CLI | 预压缩提取 |
-| **Supermemory** | 云端 | 付费 | 4 | `supermemory` | 上下文隔离 + 会话图谱导入 + 多容器 |
+| **Supermemory** | 云端/自托管 | 免费/付费 | 4 | `supermemory` | 上下文隔离 + 会话图谱导入 + 多容器 |
## Profile 隔离
@@ -546,4 +567,4 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
## 构建记忆提供者
-参见[开发者指南:Memory Provider 插件](/developer-guide/memory-provider-plugin)了解如何创建自己的提供者。
\ No newline at end of file
+参见[开发者指南:Memory Provider 插件](/developer-guide/memory-provider-plugin)了解如何创建自己的提供者。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md
index 50e26c39742b..0a0561c9090b 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/x-search.md
@@ -46,9 +46,13 @@ hermes tools
# ~/.hermes/config.yaml
x_search:
# 用于 Responses 调用的 xAI 模型。
- # grok-4.20-reasoning 是推荐的默认值;任何支持
+ # grok-4.5 是推荐的默认值;任何支持
# x_search 工具访问权限的 Grok 模型均可使用。
- model: grok-4.20-reasoning
+ model: grok-4.5
+
+ # 可选推理强度:low、medium、high 或 xhigh。省略时使用所选模型的默认值。
+ # xhigh 仅适用于明确支持它的模型,例如 grok-4.20-multi-agent。
+ # reasoning_effort: low
# 请求超时时间(秒)。复杂查询的 x_search 可能需要 60–120 秒,
# 默认值较为宽松。最小值:30。
@@ -59,6 +63,9 @@ x_search:
retries: 2
```
+`reasoning_effort` 会以 `reasoning: {effort: ...}` 的形式发送到 xAI
+Responses API。不支持可配置推理的模型应留空。无效值会在发起 API 请求前失败。
+
## 工具参数
agent 调用 `x_search` 时使用以下参数:
@@ -114,7 +121,7 @@ agent 将:
### "`x_search` is not enabled for this model"
-配置的 `x_search.model` 没有访问服务端 `x_search` 工具的权限。请切换至 `grok-4.20-reasoning`(默认值)或其他支持该工具的 Grok 模型。当前支持列表请查阅 [xAI 文档](https://docs.x.ai/)。
+配置的 `x_search.model` 没有访问服务端 `x_search` 工具的权限。请切换至 `grok-4.5`(默认值)或其他支持该工具的 Grok 模型。当前支持列表请查阅 [xAI 文档](https://docs.x.ai/)。
### 工具未出现在 schema 中
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/dingtalk.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/dingtalk.md
index 366edd5863b3..3595ef81615a 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/dingtalk.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/dingtalk.md
@@ -154,7 +154,7 @@ gateway:
- `group_sessions_per_user: true` 在共享群聊中保持每个参与者的上下文隔离
- `require_mention: true` 防止机器人响应每条群消息——仅在有人 @提及 时才回答
-- `dingtalk.extra` 下的 `allowed_users` 是 `DINGTALK_ALLOWED_USERS` 的替代方式;若两者同时设置,则合并生效
+- `dingtalk.extra` 下的 `allowed_users` 是 `DINGTALK_ALLOWED_USERS` 的替代方式;两者择一配置(若同时设置,只有同时出现在两个列表中的用户才会被授权)
### 启动 Gateway
diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json
index cad0d418c806..4711abbd87b6 100644
--- a/website/static/api/model-catalog.json
+++ b/website/static/api/model-catalog.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "updated_at": "2026-07-16T19:45:30Z",
+ "updated_at": "2026-07-20T08:23:45Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"