Merge current upstream into fix/remote-bot-routing

Preserve the connection-bound Bot and session routing implementation while
adopting upstream's modular Desktop API split and all changes through
b2057c168.
This commit is contained in:
Sara Burke
2026-08-19 16:14:41 -04:00
249 changed files with 11940 additions and 9735 deletions
+5
View File
@@ -913,6 +913,10 @@ def init_agent(
# Model response configuration
agent.max_tokens = max_tokens # None = use model default
agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
# Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in).
# Read once at init; switch_model / try_activate_fallback / restore
# keep it in sync with the active provider.
agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config()
agent.service_tier = service_tier
agent.request_overrides = dict(request_overrides or {})
agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns
@@ -2951,6 +2955,7 @@ def init_agent(
"client_kwargs": dict(agent._client_kwargs),
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False),
# Context engine state that _try_activate_fallback() overwrites.
# Use getattr for model/base_url/api_key/provider since plugin
# engines may not have these (they're ContextCompressor-specific).
+7
View File
@@ -1349,6 +1349,7 @@ def try_recover_primary_transport(
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent.api_key = rt["api_key"]
agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False)
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
@@ -1579,6 +1580,7 @@ def restore_primary_runtime(agent) -> bool:
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent.api_key = rt["api_key"]
agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False)
agent._client_kwargs = dict(rt["client_kwargs"])
agent._use_prompt_caching = rt["use_prompt_caching"]
# Default to native layout when the restored snapshot predates the
@@ -2652,6 +2654,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"_anthropic_base_url",
"_is_anthropic_oauth",
"_config_context_length",
"_reasoning_echo_flag",
)
}
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
@@ -2685,6 +2688,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent.model = new_model
agent.provider = new_provider
agent.requested_provider = new_provider
# Re-read reasoning_echo from config so the flag reflects the new
# primary model's setting (see _reasoning_echo_opt_in).
agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config()
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
@@ -2970,6 +2976,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
"reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False),
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
+4
View File
@@ -2652,6 +2652,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent.requested_provider = fb_provider
agent.base_url = fb_base_url
agent.api_mode = fb_api_mode
# Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in).
# Read from the fallback entry so the flag travels with the active
# provider; restore_primary_runtime will revert it from the snapshot.
agent._reasoning_echo_flag = bool(fb.get("reasoning_echo", False))
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent._fallback_activated = True
+2 -2
View File
@@ -5,8 +5,8 @@
* model's tool_call_id while `clarify.request` carries a gateway-generated
* request_id. A batch payload has no top-level `question`, so the two rows
* only merge when the correlation key comes from the question list
* (`batchClarifyMatchValue` in lib/chat-messages.ts). Before that fix this
* exact flow rendered two identical interactive cards.
* (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that
* fix this exact flow rendered two identical interactive cards.
*
* The flow runs the real chain: composer → gateway → agent → clarify tool →
* clarify.request event → renderer, against the mock inference server.
+19
View File
@@ -50,6 +50,25 @@ test.describe('dev-mode boot with mock backend', () => {
})
})
// A preload that throws never reaches contextBridge, so the renderer boots
// into "Desktop IPC bridge is unavailable" and every test below it dies on a
// 120s never-became-ready timeout instead. Checking the bridge by name makes
// that failure legible. The sandbox lets preload require only electron,
// events, timers and url — adding any other node builtin lands here.
test('the preload bridge reaches the renderer', async () => {
const bridge = await fixture!.page.evaluate(() => {
const desktop = (window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop
return {
present: typeof desktop,
glassSupported: typeof desktop?.glassSupported,
translucencySupported: typeof desktop?.translucencySupported
}
})
expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' })
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
+197
View File
@@ -0,0 +1,197 @@
// IPC surface for local filesystem operations the renderer's project/file
// surfaces use: directory reads, reveal/open in the OS file manager, plugin
// roots + git installs, rename/write/trash. Extracted from main.ts; path
// hardening, HERMES_HOME resolution, and the git binary stay injected.
import fs from 'node:fs'
import path from 'node:path'
import { ipcMain, shell } from 'electron'
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
import { readDirForIpc } from './fs-read-dir'
import { gitRootForIpc } from './git-root'
export interface FsIpcDeps {
hermesHome: string
readActiveDesktopProfile: () => null | string
expandUserPath: (value: string) => string
resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string
directoryExists: (value: string) => boolean
resolveGitBinary: () => string
}
export function registerFsIpc({
hermesHome,
readActiveDesktopProfile,
expandUserPath,
resolveRequestedPathForIpc,
directoryExists,
resolveGitBinary
}: FsIpcDeps) {
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
// `reveal` (which selects an existing item and silently no-ops on a missing
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
// which often doesn't exist on first use. `shell.openPath` returns '' on
// success or an error string; both mkdir + openPath failures are surfaced.
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
const dir = String(dirPath || '').trim()
if (!dir) {
return { ok: false, error: 'no path' }
}
try {
await fs.promises.mkdir(dir, { recursive: true })
const error = await shell.openPath(path.normalize(dir))
return error ? { ok: false, error } : { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
})
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
// the connected backend. A remote backend reports its own `hermes_home` over
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
// so it stays valid in every connection mode. Created on demand, like openDir.
async function localPluginsRoot(dirName: string): Promise<string> {
// Profile-aware: a named Desktop profile gets its own plugin root under
// profiles/<name>/, matching the profile-scoped hermes_home the backend
// reported before this resolver existed. 'default'/unset pins the global root.
const profile = readActiveDesktopProfile()
const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome
const dir = path.join(base, dirName)
try {
await fs.promises.mkdir(dir, { recursive: true })
} catch {
// Best-effort create; return the path regardless so the reveal action can
// still surface a real openPath error and the scanner can retry later.
}
return dir
}
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
// resolution as above. This is the desktop half of a UNIFIED plugin package:
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
// this root for it — one installable folder serving both SDKs.
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
const identifier = String(payload?.identifier || payload?.repo || '').trim()
if (!identifier) {
return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] }
}
return probePluginRepo(resolveGitBinary(), identifier)
})
ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => {
const identifier = String(payload?.identifier || payload?.repo || '').trim()
if (!identifier) {
return { ok: false, error: 'identifier is required' }
}
const desktopPluginsRoot = await localPluginsRoot('desktop-plugins')
return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force))
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
}
+120
View File
@@ -0,0 +1,120 @@
// IPC surface for git-driven features: worktree management ("Start work"),
// the composer coding rail's repo status, the Codex-style review pane, and
// repo-first project discovery. Extracted from main.ts; the git/gh binary
// resolvers stay injected because main.ts also uses them for self-update and
// plugin installs.
import { ipcMain } from 'electron'
import { scanGitRepos } from './git-repo-scan'
import {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewFetchPrComment,
reviewList,
reviewPrList,
reviewPush,
reviewRevert,
reviewRevParse,
reviewShipInfo,
reviewStage,
reviewUnstage
} from './git-review-ops'
import {
addWorktree,
listBaseBranches,
listBranches,
listWorktrees,
removeWorktree,
switchBranch
} from './git-worktree-ops'
export interface GitIpcDeps {
resolveGitBinary: () => string
resolveGhBinary: () => string
}
export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
listBaseBranches(repoPath, resolveGitBinary())
)
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
)
ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) =>
reviewFetchPrComment(repoPath, resolveGhBinary(), url)
)
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
}
+10 -18
View File
@@ -3,31 +3,23 @@ import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with
// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare
// specifier would typecheck and then fail to bundle.
import {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB
} from '../../shared/src/data-url-read-max'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
// image preview, etc.). The whole file is base64-buffered in main, so this is
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
// values can OOM / crash the app).
const DATA_URL_READ_DEFAULT_MAX_MB = 16
const DATA_URL_READ_MIN_MAX_MB = 1
const DATA_URL_READ_MAX_MAX_MB = 4096
// Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach
// reader so the payload still fits uvicorn's raised ws_max_size (384 MiB)
// after base64 + framing. Preview stays on the Settings-configurable path.
const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
function clampDataUrlReadMaxMb(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
function dataUrlReadMaxBytesFromMb(maxMb) {
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
}
+128
View File
@@ -0,0 +1,128 @@
// IPC surface for HUD mode (the chrome-free floating chat band). Extracted
// from main.ts; the HUD window handle and session-id latch stay injected
// because main.ts owns the window lifecycle and the close broadcast reads the
// latch when handing the session back to the app window.
import { type BrowserWindow, ipcMain } from 'electron'
export interface HudIpcDeps {
isMac: boolean
getHudWindow: () => BrowserWindow | null
openHudWindow: (sessionId: null | string, profile: null | string) => void
closeHudWindow: () => void
setHudSessionId: (sessionId: null | string) => void
}
export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) {
ipcMain.handle('hermes:hud:open', async (_event, request) => {
openHudWindow(
typeof request?.sessionId === 'string' ? request.sessionId : null,
typeof request?.profile === 'string' ? request.profile : null
)
return { ok: true }
})
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. Vibrancy IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and the renderer only turns it on while the band is
// showing (idle HUD mode must be the bar and nothing else).
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed() && isMac) {
hudWindow.setVibrancy(on ? 'hud' : null)
}
return { ok: true }
})
// Let clicks fall through the HUD wherever it isn't really there. An
// always-on-top window eats every click inside its rectangle, and most of that
// rectangle is a faded-out band over whatever the user is actually working in.
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
// reaches the bar.
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
ipcMain.on('hermes:hud:move-by', (event, delta) => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) {
return
}
const dx = Number(delta?.x)
const dy = Number(delta?.y)
const width = Number(delta?.width)
const height = Number(delta?.height)
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) {
return
}
const [x, y] = hudWindow.getPosition()
// setBounds — NOT setPosition: on Windows, a transparent frameless window
// silently grows ~1px per setPosition call (worse at >100% DPI). The renderer
// snapshots outerWidth/outerHeight when the composer drag arms and re-pins
// to that size on every moveBy (same pattern as the pet overlay drag).
hudWindow.setBounds({
x: Math.round(x + dx),
y: Math.round(y + dy),
width: Math.round(width),
height: Math.round(height)
})
})
// Resize from the HUD's corner handle. The window is created non-resizable
// (see spawnHudWindow — a transparent frameless window must not expose a
// system resize hot-zone, or dragging grows it), which on Windows/Linux also
// blocks programmatic setBounds sizing — so briefly flip resizable on while
// the size actually changes, exactly like the pet overlay's wheel-scale does.
ipcMain.on('hermes:hud:set-bounds', (event, bounds) => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) {
return
}
const win = hudWindow
const width = Math.max(380, Math.round(Number(bounds.width)))
const height = Math.max(160, Math.round(Number(bounds.height)))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height })
if (resizing) {
win.setResizable(false)
}
})
// The HUD renderer reporting which session it is on, so the close broadcast
// can hand it back to the app window (see hudSessionId).
ipcMain.on('hermes:hud:session', (event, sessionId) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null)
}
})
ipcMain.handle('hermes:hud:close', async () => {
closeHudWindow()
return { ok: true }
})
}
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
// IPC surface for the pop-out pet overlay (mascot window). Extracted from
// main.ts; window handles stay injected because main.ts owns their lifecycle.
import { type BrowserWindow, ipcMain } from 'electron'
export interface PetOverlayIpcDeps {
getMainWindow: () => BrowserWindow | null
getPetOverlayWindow: () => BrowserWindow | null
openPetOverlay: (bounds: unknown) => void
closePetOverlay: () => void
}
export function registerPetOverlayIpc({
getMainWindow,
getPetOverlayWindow,
openPetOverlay,
closePetOverlay
}: PetOverlayIpcDeps) {
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's
// content origin so the pet lands where it sat in-window. A remembered/dragged
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
// resolved screen bounds so the renderer can persist exactly where it opened.
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
const bounds = request && request.bounds ? request.bounds : request
const isScreen = Boolean(request && request.screen)
const mainWindow = getMainWindow()
let screenBounds = bounds
try {
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
const content = mainWindow.getContentBounds()
screenBounds = {
x: content.x + (bounds.x || 0),
y: content.y + (bounds.y || 0),
width: bounds.width,
height: bounds.height
}
}
} catch {
// Fall back to raw bounds if the window geometry is unavailable.
}
openPetOverlay(screenBounds)
return { ok: true, bounds: screenBounds }
})
ipcMain.handle('hermes:pet-overlay:close', async () => {
closePetOverlay()
return { ok: true }
})
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
const petOverlayWindow = getPetOverlayWindow()
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
// the sprite so transparent margins pass clicks to whatever is behind.
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
const petOverlayWindow = getPetOverlayWindow()
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
// The overlay is a non-activating panel (focusable:false) so it never steals
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
// while the composer is open, then back to non-activating when it closes.
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
const petOverlayWindow = getPetOverlayWindow()
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
return
}
petOverlayWindow.setFocusable(Boolean(focusable))
if (focusable) {
petOverlayWindow.focus()
}
})
// Main renderer → overlay: forward the latest pet state for the overlay to render.
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
const petOverlayWindow = getPetOverlayWindow()
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
}
})
// Overlay → main renderer: control messages (pop back in, composer submit).
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
const mainWindow = getMainWindow()
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Double-click toggles the app window: hide it away if it's up front, bring it
// back if it's minimized/buried. Pure window control — nothing for the
// renderer to do, so don't forward it.
if (payload && payload.type === 'toggle-app') {
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
mainWindow.show()
mainWindow.focus()
} else {
mainWindow.minimize()
}
return
}
// The mail icon means "take me to the app": raise the main window (it may be
// minimized or buried) before the renderer navigates to the latest thread.
if (payload && payload.type === 'open-app') {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
}
+11
View File
@@ -1,6 +1,17 @@
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
// Which translucency the OS can back. Asked synchronously because the renderer
// needs it before its first paint, and answered by main because deciding it
// needs `os.release()` — a sandboxed preload may only require electron, events,
// timers and url, so importing node:os here throws before contextBridge runs
// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined =>
// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades
// to an ordinary opaque window rather than a page thinned over nothing.
const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support')
contextBridge.exposeInMainWorld('hermesDesktop', {
glassSupported: translucencySupport?.glass === true,
translucencySupported: translucencySupport?.translucency === true,
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
+13 -20
View File
@@ -28,6 +28,7 @@
import crypto from 'node:crypto'
import { parseRemoteProfileListing } from './connection-registry'
import { assertBootstrapNotSuperseded } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
@@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT
const remoteLog = expandRemotePath(logPath)
while (Date.now() < deadline) {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
if (isAlive && !(await isAlive())) {
const err: any = new Error('Remote dashboard process exited before announcing its port.')
@@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) {
}
}
function assertNotAborted(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
function isForwardBindCollision(error) {
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
}
@@ -769,7 +762,7 @@ async function connect(deps) {
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const platform = await probeRemotePlatform(ssh)
log(`remote platform ${platform.os}/${platform.arch}`)
const hermesPath = await locateHermes(ssh, remoteHermesPath)
@@ -810,7 +803,7 @@ async function connect(deps) {
lock.hermesHome === hermesHome
if (reusable) {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const localPort = await openForward(deps, lock.port)
try {
@@ -827,7 +820,7 @@ async function connect(deps) {
}
if (reuseClassification === 'authenticated-stale') {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
await cancelForwardSafe(deps, localPort, lock.port)
await cleanupStale(ssh, ownershipId, lock)
} else if (reuseClassification === 'authenticated-ok') {
@@ -840,7 +833,7 @@ async function connect(deps) {
'reused remote dashboard'
)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
return {
@@ -868,12 +861,12 @@ async function connect(deps) {
throw error
}
} else {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
await cleanupStale(ssh, ownershipId, lock, pidAlive)
}
}
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const spawnToken = mintToken()
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
@@ -914,21 +907,21 @@ async function connect(deps) {
isAlive: () => remotePidAlive(ssh, pid),
signal
})
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
log(`remote dashboard bound port ${remotePort}`)
localPort = await openForward(deps, remotePort)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, spawnToken)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const tokenFingerprint = fingerprintToken(token)
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
return {
baseUrl,
+12
View File
@@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) {
return new SshConnection(config, { ...options, mux: false })
}
// Bootstrap loops poll a remote for readiness; a newer attempt aborts the
// signal so the stale one stops polling and unwinds. `superseded` tells the
// caller this was replaced, not that it failed.
function assertBootstrapNotSuperseded(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
export {
assertBootstrapNotSuperseded,
baseSshOptions,
buildControlArgs,
buildExecArgs,
+377
View File
@@ -0,0 +1,377 @@
// The embedded terminal's PTY host: shell resolution, env scrubbing, session
// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the
// factory owns the session map and returns the dispose helpers main.ts needs
// for SSH teardown. findOnPath / logging / connection routing stay injected.
import { execFile } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { app, ipcMain } from 'electron'
import nodePty from 'node-pty'
import { resolveTerminalConnection } from './connection-apply'
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
import { buildInteractiveSshArgs } from './ssh-connection'
import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle'
export interface TerminalIpcDeps {
isWindows: boolean
findOnPath: (command: string) => null | string
rememberLog: (line: string) => void
activeSshTerminalTarget: () => unknown
ensureBackend: () => Promise<unknown>
getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string }
}
export interface TerminalIpcApi {
disposeTerminalSession: (id: string) => boolean
disposeTerminalSessionsForSshScope: (scope: string) => void
disposeAllTerminalSessions: () => void
}
export function registerTerminalIpc({
isWindows,
findOnPath,
rememberLog,
activeSshTerminalTarget,
ensureBackend,
getSshConnectionState
}: TerminalIpcDeps): TerminalIpcApi {
const terminalSessions = new Map()
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
}
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
function posixShellSpec(shellPath) {
const shellName = path.basename(shellPath)
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
return { args: interactiveArgs, command: shellPath, name: shellName }
}
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
// prefer it only after PowerShell 7+ (`pwsh`).
function windowsPowerShellPath() {
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
}
// Map a resolved shell path to its spawn spec, picking interactive flags by
// family: PowerShell drops its logo banner (so the prompt sits flush like the
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
// gets POSIX interactive-login flags.
function shellSpecFor(shellPath) {
const name = path.basename(shellPath).toLowerCase()
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
return { args: ['-NoLogo'], command: shellPath, name }
}
if (name.startsWith('cmd')) {
return { args: [], command: shellPath, name }
}
return posixShellSpec(shellPath)
}
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
// 5.1, then comspec/cmd.exe as the universal fallback.
function windowsShellSpec() {
const command =
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
return shellSpecFor(command)
}
// Resolve the interactive shell for the embedded terminal: an explicit user
// override wins, otherwise auto-detect the best one installed for the platform.
function terminalShellCommand() {
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
// node-pty can't spawn natively.
const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim()
if (override) {
const resolved = isExecutableFile(override) ? override : findOnPath(override)
if (resolved) {
return shellSpecFor(resolved)
}
}
if (isWindows) {
return windowsShellSpec()
}
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
return posixShellSpec(shellPath || '/bin/sh')
}
function safeTerminalCwd(cwd) {
const candidate = path.resolve(String(cwd || app.getPath('home')))
try {
const stat = fs.statSync(candidate)
return stat.isDirectory() ? candidate : path.dirname(candidate)
} catch {
return app.getPath('home')
}
}
function terminalShellEnv() {
const env = { ...process.env }
// Electron is commonly launched through `npm run dev`; do not leak npm's
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
for (const key of Object.keys(env)) {
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
delete env[key]
}
}
// Strip color/theme-detection vars that ride along when Electron is launched
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
delete env.NO_COLOR
delete env.FORCE_COLOR
delete env.COLORFGBG
env.COLORTERM = 'truecolor'
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
env.TERM = 'xterm-256color'
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
// which marks the agent *backend* and gates cron/gateway behavior.
env.HERMES_DESKTOP_TERMINAL = '1'
return env
}
function terminalChannel(id, suffix) {
return `hermes:terminal:${id}:${suffix}`
}
// Best-effort read of a live PTY child's current working directory so a
// reopened tab can restart the shell where the user last `cd`'d, instead of the
// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on
// POSIX; Windows has no cheap per-process cwd query without a native module, so
// it returns null and the caller falls back to the launch cwd.
function readProcessCwd(pid) {
return new Promise(resolve => {
if (!Number.isInteger(pid) || pid <= 0) {
resolve(null)
return
}
if (process.platform === 'linux') {
fs.promises
.readlink(`/proc/${pid}/cwd`)
.then(target => resolve(target || null))
.catch(() => resolve(null))
return
}
if (process.platform === 'darwin') {
// lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line.
execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const line = String(stdout || '')
.split('\n')
.find(entry => entry.startsWith('n'))
resolve(line ? line.slice(1) : null)
})
return
}
resolve(null)
})
}
function disposeTerminalSession(id: string) {
const sessionInfo = terminalSessions.get(id)
if (!sessionInfo) {
return false
}
terminalSessions.delete(id)
try {
sessionInfo.pty.kill()
} catch {
// Process may already be gone.
}
return true
}
// SSH teardown: close every pane whose PTY rode the disconnected tunnel.
function disposeTerminalSessionsForSshScope(scope: string) {
for (const [id, info] of [...terminalSessions.entries()]) {
if (info.sshScope === scope) {
disposeTerminalSession(id)
}
}
}
// App shutdown: kill every open PTY before environment teardown.
function disposeAllTerminalSessions() {
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
}
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
// bit; the dev flow resolves node-pty straight from node_modules (nothing
// chmods it there), so the first terminal spawn dies with `posix_spawnp
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
// builds already stage an executable copy, so this is a no-op there.
let _spawnHelperEnsured = false
function ensureNodePtySpawnHelper() {
if (_spawnHelperEnsured || isWindows) {
return
}
_spawnHelperEnsured = true
try {
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
for (const helperPath of fixed) {
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
}
for (const failure of errors) {
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
}
} catch (error) {
rememberLog(
`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`
)
}
}
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureNodePtySpawnHelper()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend)
const remote = Boolean(sshTarget)
const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null
const remoteCommand =
remoteState?.remotePlatform === 'Windows'
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
: undefined
const ptyProcess = remote
? nodePty.spawn(
process.platform === 'win32'
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
: 'ssh',
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),
{ cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }
)
: nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })
terminalSessions.set(id, {
pty: ptyProcess,
webContentsId: event.sender.id,
...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {})
})
const send = (suffix, payload) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), payload)
}
ptyProcess.onData(data => send('data', data))
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
send('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name }
})
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
sessionInfo.pty.write(String(data || ''))
return true
})
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
sessionInfo.pty.resize(cols, rows)
return true
})
ipcMain.handle('hermes:terminal:cwd', async (_event, id) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return null
}
return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid)
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions }
}
+167 -12
View File
@@ -11,6 +11,7 @@
import { describe, expect, it } from 'vitest'
import {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
@@ -18,6 +19,9 @@ import {
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
normalizeMaterial,
normalizeMode,
@@ -28,9 +32,12 @@ import {
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowBackingOptions,
windowOpacityFor
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD
} from './translucency'
/** The linear ramp the curve replaced. Endpoints must still agree with it. */
@@ -38,13 +45,15 @@ const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7
const clear = (intensity: number): TranslucencyState => ({
intensity,
fade: 0,
mode: 'clear',
material: DEFAULT_GLASS_MATERIAL,
scope: DEFAULT_GLASS_SCOPE
})
const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL): TranslucencyState => ({
const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({
intensity,
fade,
mode: 'glass',
material,
scope: DEFAULT_GLASS_SCOPE
@@ -78,7 +87,7 @@ describe('clampIntensity', () => {
})
describe('normalizeMode', () => {
it('accepts glass on macOS only — there is no vibrancy to ride elsewhere', () => {
it('accepts glass only on a platform that has a native material', () => {
expect(normalizeMode('glass', true)).toBe('glass')
expect(normalizeMode('glass', false)).toBe('clear')
})
@@ -90,7 +99,7 @@ describe('normalizeMode', () => {
// Glass is pre-selected so the better half of the feature is the one you
// find, which is free because the intensity still starts at 0.
it('pre-selects glass on macOS when nothing is recorded', () => {
it('pre-selects glass when the platform supports it and nothing is recorded', () => {
expect(normalizeMode(undefined, true)).toBe('glass')
expect(normalizeMode('acrylic', true)).toBe('glass')
expect(normalizeMode(42, true)).toBe('glass')
@@ -163,11 +172,23 @@ describe('windowOpacityFor', () => {
expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX)))
})
it('never fades the native window in glass mode — the renderer paints that effect', () => {
// The tint is painted by the renderer, so the intensity lever must never
// reach setOpacity under glass — that separation is what keeps text sharp.
it('ignores the intensity lever entirely in glass mode', () => {
expect(windowOpacityFor(glass(0))).toBe(1)
expect(windowOpacityFor(glass(60))).toBe(1)
expect(windowOpacityFor(glass(100))).toBe(1)
})
it('fades a glass window only through its own lever, on the ramp clear uses', () => {
expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1)
expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40)))
expect(windowOpacityFor(glass(0, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100)))
})
it('leaves fade inert under clear, where the intensity lever already is the opacity', () => {
expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40)))
})
})
describe('glassSurfaceKeep', () => {
@@ -224,10 +245,125 @@ describe('vibrancyFor', () => {
})
})
describe('glassSupportedOn', () => {
it('is on for macOS regardless of kernel version', () => {
expect(glassSupportedOn('darwin')).toBe(true)
expect(glassSupportedOn('darwin', '24.6.0')).toBe(true)
})
it('is on for Windows 11 22H2 and newer, off for everything older', () => {
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true)
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true)
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false)
expect(glassSupportedOn('win32', '10.0.19045')).toBe(false)
expect(glassSupportedOn('win32', '10.0')).toBe(false)
expect(glassSupportedOn('win32', '')).toBe(false)
})
it('is off on Linux — Electron has no first-party desktop material there', () => {
expect(glassSupportedOn('linux', '6.8.0')).toBe(false)
})
})
describe('backgroundMaterialFor', () => {
it('is none while glass is off so DWM does not keep drawing under the backing', () => {
expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none')
expect(backgroundMaterialFor(clear(60))).toBe('none')
})
it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => {
expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic')
expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed')
expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica')
})
// Windows 11 has three system materials for four rungs, so the two heaviest
// land on mica. The mapping stays total — a saved 'header' still resolves —
// and the picker drops the duplicate instead (see glassMaterialsFor).
it('collapses Glare onto mica with Bright', () => {
expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica')
expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar')))
})
it('resolves every shipped rung to a real system material', () => {
for (const material of GLASS_MATERIALS) {
expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material)))
}
})
})
describe('translucencySupportedOn', () => {
it('covers the two platforms where setOpacity or a native material exists', () => {
expect(translucencySupportedOn('darwin')).toBe(true)
expect(translucencySupportedOn('win32')).toBe(true)
})
// Electron documents setOpacity as doing nothing on Linux, and there is no
// material either — so the setting has no working half to offer there.
it('is off on Linux, where neither mode does anything', () => {
expect(translucencySupportedOn('linux')).toBe(false)
expect(translucencySupportedOn('freebsd')).toBe(false)
})
// Win10 loses glass but keeps clear, so the row must survive there.
it('stays on for a Windows build too old for glass', () => {
const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`
expect(glassSupportedOn('win32', oldWindows)).toBe(false)
expect(translucencySupportedOn('win32')).toBe(true)
})
})
describe('the frost rungs a platform offers', () => {
it('offers the whole ladder on macOS', () => {
expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS)
})
// The census rule, now enforced on Windows too: no two options in the picker
// may composite to the same thing. Bright and Glare are both mica.
it('never offers two rungs that render the same Windows backdrop', () => {
const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))
expect(new Set(backdrops).size).toBe(backdrops.length)
expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length)
})
it('keeps every distinct Windows backdrop reachable from the picker', () => {
const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))))
const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material))))
expect(backdrops).toEqual(reachable)
})
// Settings synced from a Mac carry a rung Windows has no button for. The
// picker highlights the button that renders the same backdrop rather than
// showing nothing selected — and does NOT rewrite what the Mac saved.
it('folds a dropped rung onto the button that looks the same', () => {
expect(glassMaterialForPicker('header', true)).toBe('titlebar')
expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true))
expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe(
backgroundMaterialFor(glass(60, 'header'))
)
})
it('leaves every rung alone on macOS and every offered rung alone on Windows', () => {
for (const material of GLASS_MATERIALS) {
expect(glassMaterialForPicker(material, false)).toBe(material)
}
for (const material of glassMaterialsFor(true)) {
expect(glassMaterialForPicker(material, true)).toBe(material)
}
})
})
describe('normalizeState', () => {
it('parses a modern payload', () => {
expect(normalizeState({ intensity: 40, mode: 'glass', material: 'header', scope: 'sidebar' }, true)).toEqual({
expect(
normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true)
).toEqual({
intensity: 40,
fade: 15,
mode: 'glass',
material: 'header',
scope: 'sidebar'
@@ -239,19 +375,28 @@ describe('normalizeState', () => {
it('keeps a legacy intensity-only payload on clear', () => {
expect(normalizeState({ intensity: 70 }, true)).toEqual({
intensity: 70,
fade: 0,
mode: 'clear',
material: DEFAULT_GLASS_MATERIAL,
scope: DEFAULT_GLASS_SCOPE
})
})
it('survives junk payloads', () => {
const base = { intensity: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }
// Fade arrived after glass shipped, so a profile written by the older build
// has no key for it and must come back unfaded rather than undefined.
it('defaults a payload written before fade existed to no fade', () => {
expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0)
})
// A fresh macOS profile lands on glass at zero intensity: selected, but off.
it('survives junk payloads', () => {
const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }
// A fresh glass-capable profile lands on glass at zero intensity: selected, but off.
expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' })
expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' })
expect(normalizeState({ intensity: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)).toEqual({
expect(
normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)
).toEqual({
...base,
mode: 'clear'
})
@@ -266,8 +411,8 @@ describe('glassActive', () => {
})
})
// The default must be selected-but-off: a fresh macOS profile shows Glass in
// the picker while the window itself is untouched until the lever moves.
// The default must be selected-but-off: a fresh glass-capable profile shows
// Glass in the picker while the window itself is untouched until the lever moves.
describe('a fresh profile', () => {
const fresh = normalizeState(null, true)
@@ -326,12 +471,22 @@ describe('what an update actually changes natively', () => {
expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true })
})
// The one glass drag that reaches main, and it costs what a clear drag costs.
it('is only the opacity while dragging fade under glass', () => {
expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({
backing: false,
material: false,
opacity: true
})
})
it('is the material alone when the frost level changes', () => {
expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({
backing: false,
material: true,
opacity: false
})
expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header')))
})
// Crossing zero flips glass on/off, which is exactly when the backing has to
+9 -1
View File
@@ -14,6 +14,7 @@
import { glassActive, type TranslucencyState } from '../../shared/src/translucency'
export {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
@@ -21,6 +22,9 @@ export {
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
normalizeMaterial,
normalizeMode,
@@ -31,8 +35,12 @@ export {
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowOpacityFor
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD,
type WindowsBackgroundMaterial
} from '../../shared/src/translucency'
/**
@@ -5,9 +5,9 @@
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
// the real hermes.cmd/hermes.exe.
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
// signal), not just the hermes.exe console-script shim, or healthy
// installs get forced into a destructive --repair.
// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale
// install provenance. The bootstrap marker can outlive the venv, and a
// partial venv cannot run the updater; those states require --repair.
// 3. resolveVenvHermesCommand() — must probe the venv python via
// canImportHermesCli() before trusting it, or a broken venv gets
// re-selected forever instead of falling through to bootstrap.
@@ -45,17 +45,43 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
})
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => {
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [
'--update',
'--branch',
'main'
])
})
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => {
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'),
['--repair', '--branch', 'main']
)
})
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
test('chooseUpdaterArgs: partial updater runtimes use --repair', () => {
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [
'--repair',
'--branch',
'main'
])
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [
'--repair',
'--branch',
'main'
])
})
test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => {
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'),
['--update', '--branch', 'release/1.2']
)
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'),
['--repair', '--branch', 'release/1.2']
)
})
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
+21 -19
View File
@@ -11,12 +11,11 @@
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
* the desktop fell through to a spurious bootstrap/repair. The fix:
* PATHEXT extensions first, empty extension LAST.
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
* --update vs the destructive --repair by checking ONLY
* venv\Scripts\hermes.exe (the console-script shim, written at the END
* of venv setup and absent in interrupted states), so it escalated to a
* full venv recreate even on healthy installs. The fix: gate on ANY
* real-install signal, not just the shim.
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate
* install provenance from updater viability. A bootstrap-complete marker
* can outlive a deleted venv, while the updater needs BOTH the venv Python
* and Hermes launcher. Marker-only or partial runtimes must use --repair;
* only a runnable pair can use --update.
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
* the venv python with NO runtime probe (bypassing the caller's
* --version check too), so a venv broken mid-update (e.g. missing
@@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b
}
/**
* Choose the Windows bootstrap-recovery updater invocation: the gentle
* in-place --update when ANY real-install signal is present, the
* destructive --repair (full venv recreate) otherwise.
* Choose the Windows bootstrap-recovery invocation. The gentle in-place
* updater can only start when both pieces of its runtime contract exist: the
* venv Python interpreter and the Hermes launcher that drives `hermes update`.
* A bootstrap-complete marker proves install provenance, not current runtime
* usability, and may remain after the venv is removed or quarantined.
*
* haveRealInstall must be computed by the caller from ALL real-install
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
* marker) — gating on just the hermes.exe console-script shim alone is the
* regression this function's callers must avoid: that shim is written at
* the END of venv setup and is absent in exactly the interrupted/quarantined
* states this recovery exists to heal.
*
* @param {boolean} haveRealInstall
* @param {BootstrapRecoverySignals} signals
* @param {string} branch
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
*/
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
export interface BootstrapRecoverySignals {
hasBootstrapMarker: boolean
hasVenvHermes: boolean
hasVenvPython: boolean
}
export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] {
const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython
return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
}
/**
@@ -1,6 +1,6 @@
import crypto from 'node:crypto'
import { redactSecrets, SSH_ERROR } from './ssh-connection'
import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
const PROTOCOL_VERSION = 1
@@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) {
)
}
function assertCurrent(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
async function processState(ssh, runtime, lock) {
return helper(ssh, runtime, 'process-state', [
String(lock.pid),
@@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
let state
try {
@@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) {
readyTimeoutMs = 45_000
} = deps
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
@@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) {
await helper(ssh, runtime, 'remove-lock', [ownershipId])
}
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
const token = crypto.randomBytes(32).toString('hex')
const spawnNonce = crypto.randomBytes(8).toString('hex')
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
@@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) {
await forward(localPort, remotePort)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, token)
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
return {
+154
View File
@@ -0,0 +1,154 @@
import { JsonRpcGatewayClient } from '@hermes/shared'
import type { HermesApiRequest } from '@/global'
// Desktop startup fires a burst of read-only data calls (config, profiles,
// model info/options, cron) the moment the backend passes readiness. On a
// profile-heavy or remote install these can each take tens of seconds — e.g.
// /api/profiles runs list_profiles(), which does a recursive skill-tree walk
// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts)
// times out a backend that is alive-but-busy, surfacing as a spurious
// "Timed out connecting to Hermes backend" that hangs the UI (#48504).
//
// Give the boot burst a generous per-call timeout instead of raising the
// global default: interactive/runtime calls and the liveness poll (/api/status)
// keep the short default so a genuinely-dead backend is still detected fast.
export const STARTUP_REQUEST_TIMEOUT_MS = 60_000
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
// prompt.submit is effectively fire-and-forget: turn completion is signaled by
// stream / message.complete events, NOT by the RPC return. A long turn (MoA
// presets running references + aggregator in series, deep reasoning, large tool
// chains) can legitimately take minutes to ACK, so bounding the ack by the
// generic 30s default surfaces a false "request timed out" toast while the turn
// is still running and will succeed (issue #55024). Match the backend's
// 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 class HermesGateway extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: 'Hermes gateway connection closed',
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected',
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
})
}
}
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
// setApiRequestProfile so this module needs no store import (avoids a cycle).
// Electron main consumes request.profile as request scope. Local calls whose
// REST handlers accept profile reuse the primary dashboard via ?profile=;
// unscoped handlers retain a profile backend. Remote overrides still route to
// their owning backend. Null → primary, so single-profile users are unaffected.
let _apiProfile: null | string = null
export function setApiRequestProfile(profile: null | string): void {
_apiProfile = profile || null
}
export function profileScoped(profile?: null | string): { profile?: string } {
const selected = profile === undefined ? _apiProfile : profile
return selected ? { profile: selected } : {}
}
/** Profile that profile-scoped REST/WS calls should target (null → primary).
* Read-only twin of setApiRequestProfile for modules (e.g. voice playback)
* that build their own connection URLs and must stay on the same backend. */
export function getApiRequestProfile(): null | string {
return _apiProfile
}
// Registry connection serving the active gateway (null → the local pool).
// Pushed from store/gateway's setActive — the single seam BOTH
// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls
// that dial their own backend (pluginSocket) resolve it through the SAME
// source of truth those paths maintain for $connection. That makes the plugin
// socket follow registry-agent activations too, not just profile switches.
// Same no-store-import contract as _apiProfile (avoids a cycle).
let _apiConnectionId: null | string = null
export function setApiRequestConnection(connectionId: null | string): void {
_apiConnectionId = connectionId || null
}
// Registry connection scope for a REST request. A registered remote gateway
// owns its own state.db — cron jobs and their run sessions live THERE — so
// requests for gateway-owned data must carry the connection id for the main
// process to route them to that host (hermes:api's registry branch). Null
// resolves to no tag, keeping single-source users byte-identical; explicit
// 'local' must remain tagged when the legacy primary points elsewhere.
export function connectionScoped(): { connectionId?: string } {
return _apiConnectionId ? { connectionId: _apiConnectionId } : {}
}
/** Send a REST request to the renderer's active registry source. Request-level
* routing may override the active source for an explicitly-owned resource.
*
* Helpers under `api/` go through here rather than calling the preload bridge
* directly, so the connection tag cannot be forgotten on a new one — with one
* exception. A capabilityScoped() helper must NOT: that scope says "the local
* pool" by omitting `connectionId` entirely, and an absent key cannot override
* the ambient tag spread underneath it, so a 'local' pin would silently route
* to whatever remote gateway happened to be active. Those helpers call the
* bridge directly and own their routing end to end. */
export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
return window.hermesDesktop.api<T>({ ...connectionScoped(), ...request })
}
// ── Capability scope: (connection, profile) routing for the Capabilities
// surface (skills / toolsets / MCP / hub / env / toolset config) ────────────
//
// A profile is not a machine-global name — it belongs to ONE gateway. The
// Capabilities surface can be pointed at any (connection, profile) pair
// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
// its REST helpers accept either the legacy string form or an explicit scope
// object:
//
// - `undefined` / string → the legacy profile path, PLUS the active registry
// connection tag (connectionScoped, same contract the cron helpers adopted
// in #87882). Without the tag, a window activated onto a registered remote
// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine.
// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection
// ids mean the local pool and deliberately DROP the ambient connection
// tag, so a local-profile pick made while a remote gateway is active still
// routes to the local machine.
export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string }
export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
if (scope && typeof scope === 'object') {
const profile = (scope.profile ?? '').trim()
const connectionId = (scope.connectionId ?? '').trim()
return {
...(profile ? { profile } : {}),
...(connectionId && connectionId !== 'local' ? { connectionId } : {})
}
}
return { ...profileScoped(scope), ...connectionScoped() }
}
/** Stable cache-key for a capability scope: `profile` for the local/legacy
* path, `connectionId::profile` for an explicit remote pin. Mirrors
* normalizeProfileKey for plain strings so existing keys stay byte-identical. */
export function profileScopeKey(scope?: ProfileScope): string {
if (scope && typeof scope === 'object') {
const profile = (scope.profile ?? '').trim() || 'default'
const connectionId = (scope.connectionId ?? '').trim()
return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile
}
return (scope ?? '').trim() || 'default'
}
/** Registry connection id that connection-scoped WS calls should target
* (null → the local pool). Read-only twin of setApiRequestConnection. */
export function getApiRequestConnection(): null | string {
return _apiConnectionId
}
+239
View File
@@ -0,0 +1,239 @@
import type {
ConfigSchemaResponse,
CustomEndpointsResponse,
CustomEndpointUpdate,
CustomEndpointValidationResponse,
EnvVarInfo,
HermesConfig,
HermesConfigRecord,
LogsResponse,
OAuthPollResponse,
OAuthProvidersResponse,
OAuthStartResponse,
OAuthSubmitResponse,
StatusResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getStatus(): Promise<StatusResponse> {
return hermesApi<StatusResponse>({
...profileScoped(),
path: '/api/status'
})
}
export function getLogs(params: {
component?: string
file?: string
level?: string
lines?: number
search?: string
}): Promise<LogsResponse> {
const query = new URLSearchParams()
if (params.file) {
query.set('file', params.file)
}
if (typeof params.lines === 'number') {
query.set('lines', String(params.lines))
}
if (params.level && params.level !== 'ALL') {
query.set('level', params.level)
}
if (params.component && params.component !== 'all') {
query.set('component', params.component)
}
if (params.search) {
query.set('search', params.search)
}
const suffix = query.toString()
return hermesApi<LogsResponse>({
...profileScoped(),
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
return hermesApi<HermesConfig>({
...profileScoped(profile),
path: '/api/config',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...capabilityScoped(profile),
path: '/api/config'
})
}
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
return hermesApi<HermesConfigRecord>({
...profileScoped(),
path: '/api/config/defaults',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
return hermesApi<ConfigSchemaResponse>({
...profileScoped(profile),
path: '/api/config/schema'
})
}
export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: '/api/config',
method: 'PUT',
body: { config }
})
}
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
return hermesApi<Record<string, EnvVarInfo>>({
...profileScoped(profile),
path: '/api/env'
})
}
export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/env',
method: 'PUT',
body: { key, value }
})
}
export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/env',
method: 'DELETE',
body: { key }
})
}
export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> {
return window.hermesDesktop.api<{ key: string; value: string }>({
...capabilityScoped(profile),
path: '/api/env/reveal',
method: 'POST',
body: { key }
})
}
export function validateProviderCredential(
key: string,
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
})
}
export function getCustomEndpoints(): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: '/api/providers/custom-endpoints'
})
}
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: '/api/providers/custom-endpoints',
method: 'POST',
body: endpoint
})
}
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
return hermesApi<CustomEndpointValidationResponse>({
path: '/api/providers/custom-endpoints/validate',
method: 'POST',
body: endpoint
})
}
export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
return hermesApi<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
method: 'POST'
})
}
export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return hermesApi<OAuthProvidersResponse>({
...profileScoped(),
path: '/api/providers/oauth'
})
}
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
return hermesApi<{ ok: boolean; provider: string }>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
method: 'DELETE'
})
}
export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...capabilityScoped(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
})
}
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return hermesApi<OAuthSubmitResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
})
}
export function pollOAuthSession(
providerId: string,
sessionId: string,
profile?: ProfileScope
): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...capabilityScoped(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
}
+153
View File
@@ -0,0 +1,153 @@
import type {
AutomationBlueprint,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobUpdates,
SessionInfo
} from '@/types/hermes'
import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
// The cron trigger endpoint intentionally waits for the whole job so its
// response reflects the persisted execution result. Agent jobs can run far
// longer than the Electron fetch default; keep this override local to the one
// synchronous long-operation endpoint rather than weakening all API timeouts.
const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000
// Cron jobs are stored per-profile (<HERMES_HOME>/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<CronJob[]> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return hermesApi<CronJob[]>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await hermesApi<{ runs: SessionInfo[] }>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})
return runs ?? []
}
// The single source of truth for cron delivery targets (local + configured
// gateways). Both the manual cron editor and the blueprint dialog use this so
// they never offer a platform that isn't connected. Mirrors the dashboard.
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/delivery-targets'
})
return targets ?? []
}
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
})
}
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
})
}
export function pauseCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
}
export function resumeCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
}
export function triggerCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
method: 'POST',
timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS
})
}
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'DELETE'
})
}
// Automation Blueprints — parameterized cron templates the backend serves from
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
// (deliver options already rewritten to this machine's configured gateways);
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
// the same create_job path as createCronJob.
//
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
// list endpoint takes no profile — only deliver options are rewritten from the
// configured gateways), so it carries only the profileScoped() header for
// routing. instantiate creates a real per-profile job, so it names the target
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
return hermesApi<{ blueprints: AutomationBlueprint[] }>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/blueprints',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function instantiateAutomationBlueprint(
body: { blueprint: string; values: Record<string, string> },
profile: string
): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
method: 'POST',
body
})
}
+147
View File
@@ -0,0 +1,147 @@
import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
export interface McpTestResult {
ok: boolean
error?: string
/** `schema_chars` (converted registry-schema size, chars) is additive —
* older backends omit it and the cost overlay shows no token estimate. */
tools: { name: string; description: string; schema_chars?: number }[]
/** Capability counts (absent on older backends / failed probes). */
prompts?: number
resources?: number
}
export interface McpOAuthFlow {
flow_id: string
server_name: string
status: 'starting' | 'authorization_required' | 'approved' | 'error'
authorization_url: string | null
error: string | null
tools?: { name: string; description: string }[]
}
/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
* for real) — well past the 15s default fetch timeout. */
export function testMcpServer(name: string, profile?: ProfileScope): Promise<McpTestResult> {
return window.hermesDesktop.api<McpTestResult>({
...capabilityScoped(profile),
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
method: 'POST',
timeoutMs: 60_000
})
}
/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike
* `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes,
* re-enables (dropping `enabled: false`), and removed nested fields persist. */
export function saveMcpServers(
servers: Record<string, Record<string, unknown>>,
profile?: ProfileScope
): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/mcp/servers',
method: 'PUT',
body: { servers }
})
}
/** Start an MCP OAuth flow and return the authorization URL. */
export function authMcpServer(name: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...capabilityScoped(profile),
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
method: 'POST',
timeoutMs: 60_000
})
}
export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...capabilityScoped(profile),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`
})
}
/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server
* "already in progress" slot so a retry doesn't 409. */
export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> {
return hermesApi<{ ok: boolean; status: string }>({
...profileScoped(profile),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`,
method: 'DELETE'
})
}
// ---------------------------------------------------------------------------
// MCP servers — structured list / test / enable toggle / catalog (parity with
// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in
// config.yaml via saveHermesConfig.
// ---------------------------------------------------------------------------
export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
return hermesApi<{ servers: McpServerSummary[] }>({
...profileScoped(),
path: '/api/mcp/servers'
})
}
/** Add one server to `mcp_servers` (validated + name-collision-checked
* server-side — the same endpoint the dashboard's add form uses). */
export function addMcpServer(body: {
name: string
url?: string
command?: string
args?: string[]
env?: Record<string, string>
auth?: string
}): Promise<McpServerSummary> {
return hermesApi<McpServerSummary>({
...profileScoped(),
path: '/api/mcp/servers',
method: 'POST',
body
})
}
/** Remove one server from `mcp_servers` (the inline setup card's rollback
* when a directory install is cancelled after the config write). */
export function removeMcpServer(name: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
export function getMcpCatalog(profile?: ProfileScope): Promise<McpCatalogResponse> {
return window.hermesDesktop.api<McpCatalogResponse>({
...capabilityScoped(profile),
path: '/api/mcp/catalog'
})
}
export function installMcpCatalogEntry(
name: string,
env: Record<string, string> = {},
profile?: ProfileScope
): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({
...capabilityScoped(profile),
path: '/api/mcp/catalog/install',
method: 'POST',
body: { name, env, enable: true },
timeoutMs: 60_000
})
}
+131
View File
@@ -0,0 +1,131 @@
import type {
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
PairingResponse,
PairingUser,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhooksResponse
} from '@/types/hermes'
import { hermesApi, profileScoped } from './client'
export function getMessagingPlatforms(profile?: null | string): Promise<MessagingPlatformsResponse> {
return hermesApi<MessagingPlatformsResponse>({
...profileScoped(profile),
path: '/api/messaging/platforms'
})
}
export function updateMessagingPlatform(
platformId: string,
body: MessagingPlatformUpdate,
profile?: null | string
): Promise<{ ok: boolean; platform: string }> {
return hermesApi<{ ok: boolean; platform: string }>({
...profileScoped(profile),
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
method: 'PUT',
body
})
}
export function testMessagingPlatform(
platformId: string,
profile?: null | string
): Promise<MessagingPlatformTestResponse> {
return hermesApi<MessagingPlatformTestResponse>({
...profileScoped(profile),
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`,
method: 'POST'
})
}
// -- Pairing (who may DM the bot) --------------------------------------------
// Unknown DMers get a one-time code and land in `pending` until an admin
// approves them. Approval grants on the row's `request_id`, never on the code:
// the code is the requester's proof that the channel is theirs and is never
// returned by the API, while an authenticated admin is only ever identifying
// a row they can already see.
export function getPairing(profile?: null | string): Promise<PairingResponse> {
return hermesApi<PairingResponse>({
...profileScoped(profile),
path: '/api/pairing'
})
}
export function approvePairing(
platform: string,
requestId: string,
profile?: null | string
): Promise<{ ok: boolean; user: PairingUser }> {
return hermesApi<{ ok: boolean; user: PairingUser }>({
...profileScoped(profile),
path: '/api/pairing/approve',
method: 'POST',
// These endpoints read the profile off the body, not the query string —
// `profileScoped()` alone would approve into the wrong profile's store.
body: { platform, request_id: requestId, ...profileScoped(profile) }
})
}
export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: '/api/pairing/revoke',
method: 'POST',
body: { platform, user_id: userId, ...profileScoped(profile) }
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
// best-effort restarts the gateway; subscription changes hot-reload.
export function getWebhooks(): Promise<WebhooksResponse> {
return hermesApi<WebhooksResponse>({
...profileScoped(),
path: '/api/webhooks'
})
}
export function enableWebhooks(): Promise<WebhookEnableResponse> {
return hermesApi<WebhookEnableResponse>({
...profileScoped(),
path: '/api/webhooks/enable',
method: 'POST'
})
}
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
return hermesApi<WebhookCreateResponse>({
...profileScoped(),
path: '/api/webhooks',
method: 'POST',
body
})
}
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setWebhookEnabled(
name: string,
enabled: boolean
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
+129
View File
@@ -0,0 +1,129 @@
import type {
AnalyticsResponse,
AuxiliaryModelsResponse,
MoaConfigResponse,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
return hermesApi<ModelInfoResponse>({
...profileScoped(profile),
path: '/api/model/info',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...capabilityScoped(profile),
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
})
}
export function getGlobalModelOptions(
opts?: {
refresh?: boolean
includeUnconfigured?: boolean
explicitOnly?: boolean
},
profile?: null | string
): Promise<ModelOptionsResponse> {
const params = new URLSearchParams()
if (opts?.refresh) {
params.set('refresh', '1')
}
if (opts?.includeUnconfigured) {
params.set('include_unconfigured', '1')
}
if (opts?.explicitOnly !== false) {
params.set('explicit_only', '1')
}
return hermesApi<ModelOptionsResponse>({
...profileScoped(profile),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export interface RecommendedDefaultModel {
provider: string
model: string
/** True/false for Nous (free vs paid tier); null for other providers. */
free_tier: boolean | null
}
// Recommended default model for a freshly-authenticated provider. Mirrors the
// curation `hermes model` does — for Nous it honors the free/paid tier so a
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(
provider: string,
profile?: null | string
): Promise<RecommendedDefaultModel> {
return hermesApi<RecommendedDefaultModel>({
...profileScoped(profile),
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
export function setGlobalModel(
provider: string,
model: string
): Promise<{ ok: boolean; provider: string; model: string }> {
return hermesApi<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body: {
scope: 'main',
provider,
model
}
})
}
export function getAuxiliaryModels(profile?: null | string): Promise<AuxiliaryModelsResponse> {
return hermesApi<AuxiliaryModelsResponse>({
...profileScoped(profile),
path: '/api/model/auxiliary'
})
}
export function getMoaModels(profile?: null | string): Promise<MoaConfigResponse> {
return hermesApi<MoaConfigResponse>({
...profileScoped(profile),
path: '/api/model/moa'
})
}
export function saveMoaModels(
body: MoaConfigResponse,
profile?: null | string
): Promise<MoaConfigResponse & { ok: boolean }> {
return hermesApi<MoaConfigResponse & { ok: boolean }>({
...profileScoped(profile),
path: '/api/model/moa',
method: 'PUT',
body
})
}
export function setModelAssignment(
body: ModelAssignmentRequest,
profile?: null | string
): Promise<ModelAssignmentResponse> {
return hermesApi<ModelAssignmentResponse>({
...profileScoped(profile),
path: '/api/model/set',
method: 'POST',
body
})
}
+128
View File
@@ -0,0 +1,128 @@
import type { HermesConnection } from '@/global'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
* profile)-scoped — mirroring how store/profile resolves $connection: a
* registry agent's descriptor comes from getConnectionFor (its SOURCE
* connection), everything else from the profile-keyed local pool. The
* getConnectionFor bridge is optional (older Desktop mains); without it the
* profile-scoped pool lookup is the best available answer. */
async function activeConnection(): Promise<HermesConnection> {
const getConnectionFor = window.hermesDesktop.getConnectionFor
const connectionId = getApiRequestConnection()
if (connectionId && getConnectionFor) {
return getConnectionFor({ connectionId, profile: getApiRequestProfile() })
}
return window.hermesDesktop.getConnection(getApiRequestProfile())
}
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary — reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace — `path` is relative to `/api/plugins/<pluginId>`
* ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return hermesApi<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback — every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await activeConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame — plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
// Full-jitter exponential backoff: same rationale as the gateway
// socket reconnect loops — an immediate-retry loop across many
// desktop clients floods the gateway with connection attempts
// during a restart.
window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 }))
attempt += 1
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}
+104
View File
@@ -0,0 +1,104 @@
import type {
ProfileCreatePayload,
ProfileDesktopOverlay,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getProfiles(): Promise<ProfilesResponse> {
return hermesApi<ProfilesResponse>({
path: '/api/profiles',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
path: '/api/profiles',
method: 'POST',
body
})
}
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'PATCH',
body: { new_name: newName }
})
}
export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> {
const normalized = name.trim()
const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined
if (!normalized) {
return Promise.reject(new Error('Profile name required'))
}
if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') {
return Promise.reject(new Error('The default profile cannot be deleted.'))
}
return hermesApi<{ ok: boolean; path: string }>({
...capabilityScoped(scope),
...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local'
? { connectionId: 'local' }
: {}),
path: `/api/profiles/${encodeURIComponent(normalized)}`,
method: 'DELETE'
})
}
export function getProfileSoul(name: string): Promise<ProfileSoul> {
return hermesApi<ProfileSoul>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`
})
}
export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
method: 'PUT',
body: { content }
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return hermesApi<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
* interface overlay) into the archive alongside the profile's own artifacts. */
export function exportProfileArchive(
name: string,
opts: { extraFiles?: Record<string, string>; output?: string } = {}
): Promise<{ archive: string; ok: boolean }> {
return hermesApi<{ archive: string; ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/export`,
method: 'POST',
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
* appearance overlay too (when the archive carried one) so the caller can
* apply theme/layout without another round-trip. */
export function importProfileArchive(
archive: string,
name?: string
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
path: '/api/profiles/import',
method: 'POST',
body: { archive, name: name || null },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
+487
View File
@@ -0,0 +1,487 @@
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
import { recordTranscriptTail } from '@/store/transcript-tail'
import type {
PaginatedSessions,
SessionInfo,
SessionMessage,
SessionMessagesResponse,
SessionSearchResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope } from './client'
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
if (scope === undefined || scope === null) {
return {}
}
const scoped = capabilityScoped(scope)
if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') {
return { ...scoped, connectionId: 'local' }
}
return scoped
}
function sessionScopeQuery(scope?: ProfileScope): string {
const profile = sessionScoped(scope).profile
return profile ? `?profile=${encodeURIComponent(profile)}` : ''
}
/**
* Trim a page to its window WITHOUT discarding pinned rows.
*
* The list endpoints deliberately back-fill pinned conversations past their
* LIMIT — a pin means "always reachable", so an aged-out pinned chat is
* appended after the recency window. A plain `slice(0, limit)` throws exactly
* those rows away again, which is why pins silently stopped rendering past
* some count: the sidebar could only ever show the pins that happened to fall
* inside the most-recent page.
*/
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
if (sessions.length <= limit) {
return sessions
}
const recent = sessions.slice(0, limit)
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
}
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await hermesApi<PaginatedSessions>({
path:
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
// Unified, read-only session list aggregated across ALL profiles. Served by the
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
// profile="default".
// Source scoping lets callers split the unified list into independent slices:
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
// consumes the whole recents page and starves real conversations.
export interface SessionSourceFilter {
source?: string
excludeSources?: string[]
}
export async function listAllProfileSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all',
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''
const result = await hermesApi<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
// Batched sidebar slices in one request: recents (scoped to the active profile),
// cron, and messaging. The backend opens each profile's state.db once and runs
// all three filtered queries, replacing three separate listAllProfileSessions
// calls that each reopened + re-counted every profile DB per refresh. Electron
// splices remote profiles per slice (see interceptSessionRequestForRemote).
export interface SidebarSessionSlice {
sessions: SessionInfo[]
/** Per-profile "the window came back full, more rows exist on disk" flags —
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
profiles_truncated?: Record<string, boolean>
/** Per-profile tokens and spend over every session, not just this window.
* Absent from the legacy per-slice endpoint, which has no aggregate. */
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
}
/** Which profiles filled their per-profile window in a returned page. The
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
* a profile at (or over) the cap still has more on disk. Pinned rows are
* discounted — they're back-filled past the LIMIT, so counting them fakes a
* full page and leaves a "Load more" that can never resolve. */
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
const counts = new Map<string, number>()
for (const session of sessions) {
const key = session.profile || 'default'
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
}
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
}
export interface SidebarSessionsResponse {
recents: SidebarSessionSlice
cron: SidebarSessionSlice
messaging: SidebarSessionSlice
errors?: Array<{ profile: string; error: string }>
}
export interface SidebarSessionsRequest {
recentsProfile: 'all' | (string & {})
recentsLimit: number
recentsExclude: string[]
cronLimit: number
messagingLimit: number
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
}
// 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; every slice scoped to the caller's profile).
// Rides the same Electron remote-splice
// interception as the pre-batching desktop, so remote profiles stay correct.
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
const [recents, cron, messaging] = await Promise.all([
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.recentsExclude
}),
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }),
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.messagingExclude
})
])
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
return {
recents: {
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
sessions: recents.sessions
},
cron: { sessions: cron.sessions },
messaging: { sessions: messaging.sessions },
...(errors.length ? { errors } : {})
}
}
/** The PR each of these sessions opened, recovered from its own transcript —
* for sessions whose recorded branch can't answer (they started on trunk and
* did the work in a worktree). Also returns every id it looked at, so the
* caller can remember a miss and never ask again. */
export function scanSessionPullRequests(
ids: string[]
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
return hermesApi<{
pull_requests: Record<string, { number: number; url: string }>
scanned: string[]
}>({
path: '/api/profiles/sessions/pull-requests',
method: 'POST',
body: { ids }
})
}
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
}
const params = new URLSearchParams({
recents_profile: req.recentsProfile,
recents_limit: String(Math.max(1, req.recentsLimit)),
cron_limit: String(Math.max(1, req.cronLimit)),
messaging_limit: String(Math.max(1, req.messagingLimit))
})
if (req.recentsExclude.length) {
params.set('recents_exclude', req.recentsExclude.join(','))
}
if (req.messagingExclude.length) {
params.set('messaging_exclude', req.messagingExclude.join(','))
}
let result: SidebarSessionsResponse
try {
result = await hermesApi<SidebarSessionsResponse>({
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
} catch (err) {
// Safe to read a 404 as route-missing here: this GET has no path params,
// so it cannot 404 on a bad id.
if (!isMissingRestEndpoint(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 ?? [] },
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
errors: result.errors
}
}
// Mutations take the owning `profile` so Electron can route them to the correct
// remote backend or local profile scope. Omit for the current/default profile.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
})
}
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
// display; this only feeds the backend policy.
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { pinned }
})
}
// Mirror a sidebar unread toggle to the backend read-state watermark
// (sessions.last_read_at via SessionDB.set_session_read). Same profile
// routing as the other session mutations: a remote session's row lives only
// on its remote host, so the owning profile must travel with the request.
export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { unread }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return hermesApi<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
})
}
// Resolves a single session row by id on one backend (the active profile, or
// the given `profile`). The backend resolves exact ids and unique prefixes and
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
// cross-profile list scan when locating an unknown id's owner.
export function getSession(id: string, profile?: ProfileScope): Promise<SessionInfo> {
const suffix = sessionScopeQuery(profile)
return hermesApi<SessionInfo>({
...sessionScoped(profile),
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
})
}
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
// the current/default profile.
export function getSessionMessages(
id: string,
profile?: ProfileScope,
page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}
): Promise<SessionMessagesResponse> {
const query = new URLSearchParams()
const sessionScope = sessionScoped(profile)
if (sessionScope.profile) {
query.set('profile', sessionScope.profile)
}
if (page.limit !== undefined) {
query.set('limit', String(page.limit))
}
if (page.offset !== undefined) {
query.set('offset', String(page.offset))
}
if (page.order) {
query.set('order', page.order)
}
if (page.includeCompacted !== undefined) {
query.set('include_compacted', String(page.includeCompacted))
}
const suffix = query.size ? `?${query.toString()}` : ''
return hermesApi<SessionMessagesResponse>({
...sessionScope,
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
/**
* The initial hydration page: enough tail to fill the transcript window a few
* times over, small enough that opening a long session doesn't ship (and
* convert) hundreds of rows nobody has scrolled to. Older rows load on demand
* via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory
* store (see app/chat/transcript-backfill).
*/
export const LATEST_SESSION_MESSAGES_LIMIT = 120
export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise<SessionMessagesResponse> {
// includeCompacted: durable display history must include rows preserved by
// in-place compaction (active=0, compacted=1); without them the transcript
// silently ends at the compaction boundary and earlier turns are unreachable.
return getSessionMessages(id, profile, {
limit: LATEST_SESSION_MESSAGES_LIMIT,
order: 'latest',
includeCompacted: true
}).then(page => {
// Record whether the tail was truncated (page came back full) and where
// the next older page starts, so "Show earlier" can backfill over REST
// (app/chat/transcript-backfill). Keyed under both the requested id and
// the resolved id — callers hold either.
recordTranscriptTail(id, page, profile)
if (page.session_id && page.session_id !== id) {
recordTranscriptTail(page.session_id, page, profile)
}
return page
})
}
/**
* One page of messages OLDER than the `offset` newest rows.
*
* Backend semantics (`_handle_session_messages` → `SessionDB.get_messages`
* with `latest=True`): the offset is measured back from the NEWEST message
* and the selected page is returned in chronological order. So after a tail
* hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the
* page immediately preceding it, ready to prepend.
*
* Legacy backends without pagination support return the full transcript and
* no `pagination` metadata — callers detect that via the missing field and
* treat the response as the complete history (see transcript-backfill).
*/
export function getOlderSessionMessages(
id: string,
profile: ProfileScope,
offset: number,
limit: number = LATEST_SESSION_MESSAGES_LIMIT
): Promise<SessionMessagesResponse> {
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
}
export async function getAllSessionMessages(
id: string,
profile?: ProfileScope,
options: { maxJsonChars?: number } = {}
): Promise<SessionMessagesResponse> {
const messages: SessionMessage[] = []
const pageSize = 500
const maxJsonChars = options.maxJsonChars ?? 32_000_000
let jsonChars = 0
let offset = 0
let resolvedSessionId = id
while (true) {
const page = await getSessionMessages(id, profile, {
limit: pageSize,
offset,
order: 'oldest',
includeCompacted: true
})
resolvedSessionId = page.session_id
jsonChars += (JSON.stringify(page.messages) ?? '').length
if (jsonChars > maxJsonChars) {
throw new Error(
'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.'
)
}
messages.push(...page.messages)
// Legacy backends ignore pagination and return the full transcript.
if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) {
break
}
offset += page.messages.length
}
return { session_id: resolvedSessionId, messages }
}
export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...sessionScoped(profile),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function renameSession(
id: string,
title: string,
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return hermesApi<{ ok: boolean; title: string }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title, ...(profile ? { profile } : {}) }
})
}
+162
View File
@@ -0,0 +1,162 @@
import type {
SkillHubPreview,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSourcesResponse,
SkillInfo,
StarmapGraph
} from '@/types/hermes'
import type { ActionResponse } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
export function getSkills(profile?: ProfileScope): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...capabilityScoped(profile),
path: '/api/skills'
})
}
/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or
* learned — backing the Capabilities detail pane's full-skill view. */
export function getSkillContent(
name: string,
profile?: ProfileScope
): Promise<{ content: string; name: string; path: string }> {
return window.hermesDesktop.api<{ content: string; name: string; path: string }>({
...capabilityScoped(profile),
path: `/api/skills/content?name=${encodeURIComponent(name)}`
})
}
export function setSkillEnabled(
name: string,
enabled: boolean,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...capabilityScoped(profile),
path: '/api/skills/toggle',
method: 'PUT',
body: { name, enabled }
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return hermesApi<StarmapGraph>({
...profileScoped(),
// Backend REST contract — stays /api/learning even though the UI feature is
// now "star map". Renaming this would break against an un-upgraded backend.
path: '/api/learning/graph'
})
}
export interface LearningNodeDetail {
content: string
kind: 'memory' | 'skill'
label: string
ok: boolean
}
export function getLearningNode(id: string, profile?: ProfileScope): Promise<LearningNodeDetail> {
return window.hermesDesktop.api<LearningNodeDetail>({
...capabilityScoped(profile),
path: `/api/learning/node?id=${encodeURIComponent(id)}`
})
}
export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...capabilityScoped(profile),
path: '/api/learning/node',
method: 'DELETE',
body: { id }
})
}
export function editLearningNode(
id: string,
content: string,
profile?: ProfileScope
): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...capabilityScoped(profile),
path: '/api/learning/node',
method: 'PUT',
body: { content, id }
})
}
// ---------------------------------------------------------------------------
// Skills hub — search / preview / scan / install (parity with `hermes skills`
// and the dashboard's Browse-hub tab). Installs spawn background actions whose
// logs are tailed via getActionStatus().
// ---------------------------------------------------------------------------
const HUB_REQUEST_TIMEOUT_MS = 45_000
export function getSkillHubSources(profile?: null | string): Promise<SkillHubSourcesResponse> {
return hermesApi<SkillHubSourcesResponse>({
...profileScoped(profile),
path: '/api/skills/hub/sources',
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function searchSkillsHub(
query: string,
source = 'all',
limit = 20,
profile?: null | string
): Promise<SkillHubSearchResponse> {
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
return hermesApi<SkillHubSearchResponse>({
...profileScoped(profile),
path: `/api/skills/hub/search?${params.toString()}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function previewSkillHub(identifier: string, profile?: null | string): Promise<SkillHubPreview> {
return hermesApi<SkillHubPreview>({
...profileScoped(profile),
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function scanSkillHub(identifier: string, profile?: null | string): Promise<SkillHubScanResult> {
return hermesApi<SkillHubScanResult>({
...profileScoped(profile),
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/install',
method: 'POST',
body: { identifier }
})
}
export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/uninstall',
method: 'POST',
body: { name }
})
}
export function updateSkillsFromHub(profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/update',
method: 'POST',
body: {}
})
}
+247
View File
@@ -0,0 +1,247 @@
import type {
ActionResponse,
ActionStatusResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
BackendUpdateCheckResponse,
CuratorStatusResponse,
DebugShareResponse,
ElevenLabsVoicesResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
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)
}
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise<MemoryProviderConfig> {
return hermesApi<MemoryProviderConfig>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
export function saveMemoryProviderConfig(
provider: string,
values: Record<string, string>,
profile?: null | string
): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }
})
}
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
export function startMemoryProviderOAuth(
provider: string,
profile?: null | string
): Promise<MemoryProviderOAuthStatus> {
return hermesApi<MemoryProviderOAuthStatus>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
method: 'POST'
})
}
export function getMemoryProviderOAuthStatus(
provider: string,
profile?: null | string
): Promise<MemoryProviderOAuthStatus> {
return hermesApi<MemoryProviderOAuthStatus>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
})
}
// ---------------------------------------------------------------------------
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
// ---------------------------------------------------------------------------
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
return hermesApi<MemoryStatusResponse>({
...profileScoped(),
path: '/api/memory'
})
}
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
return hermesApi<{ ok: boolean; deleted: string[] }>({
...profileScoped(),
path: '/api/memory/reset',
method: 'POST',
body: { target }
})
}
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
return hermesApi<CuratorStatusResponse>({
...profileScoped(),
path: '/api/curator'
})
}
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
return hermesApi<{ ok: boolean; paused: boolean }>({
...profileScoped(),
path: '/api/curator/paused',
method: 'PUT',
body: { paused }
})
}
export function runCurator(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/curator/run',
method: 'POST',
body: {}
})
}
export function restartGateway(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/gateway/restart',
method: 'POST'
})
}
export function updateHermes(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/hermes/update',
method: 'POST'
})
}
/** Query the connected backend's own update state. In remote mode this is the
* authoritative source for the backend's behind-count + "what's changed",
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return hermesApi<BackendUpdateCheckResponse>({
...profileScoped(),
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
...capabilityScoped(profile),
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
})
}
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
return hermesApi<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',
method: 'POST',
...profileScoped(),
body: {
data_url: dataUrl,
mime_type: mimeType
},
// Transcription blocks until provider STT, file handling, and response
// encoding finish. Remote providers and long clips regularly exceed the
// default 15s Electron backend timeout.
timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)
})
}
export function speakText(text: string): Promise<AudioSpeakResponse> {
return hermesApi<AudioSpeakResponse>({
...profileScoped(),
path: '/api/audio/speak',
method: 'POST',
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)
})
}
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
return hermesApi<ElevenLabsVoicesResponse>({
path: '/api/audio/elevenlabs/voices',
...profileScoped(profile)
})
}
/** `gh` CLI presence + auth state, for the composer's GitHub skill pill
* (GitHub is deliberately not an MCP — the github/* skills are the
* integration). Backend caches for 5 minutes; `refresh` bypasses. */
export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> {
return hermesApi<{ available: boolean; authenticated: boolean }>({
...profileScoped(),
path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}`
})
}
// ---------------------------------------------------------------------------
// Maintenance operations (parity with `hermes doctor` / `hermes security
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
// page). All except debug share are spawn-based background actions tailed via
// getActionStatus().
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
}
export function runSecurityAudit(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
}
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
return hermesApi<ActionResponse & { archive?: string }>({
path: '/api/ops/backup',
method: 'POST',
body: {}
})
}
export function runDebugShare(): Promise<DebugShareResponse> {
return hermesApi<DebugShareResponse>({
path: '/api/ops/debug-share',
method: 'POST',
body: {},
// Synchronous upload of report + logs to the paste service.
timeoutMs: 120_000
})
}
+141
View File
@@ -0,0 +1,141 @@
import type {
ActionResponse,
ComputerUseStatus,
TerminalBackendsResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModelsResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
// The optional trailing `profile` on every capability fetcher below is the
// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP
// panels configure ANY profile without swapping the app-wide active profile.
// Omitting it (every pre-existing caller) means `profileScoped(undefined)`
// falls back to the app-wide `_apiProfile`, so behavior is byte-identical.
export function getToolsets(profile?: ProfileScope): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...capabilityScoped(profile),
path: '/api/tools/toolsets'
})
}
export function setToolsetEnabled(
name: string,
enabled: boolean,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
method: 'PUT',
body: { enabled }
})
}
export function getToolsetConfig(name: string, profile?: ProfileScope): Promise<ToolsetConfig> {
return window.hermesDesktop.api<ToolsetConfig>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
})
}
export function getToolsetModels(
name: string,
provider?: string,
profile?: ProfileScope
): Promise<ToolsetModelsResponse> {
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
return window.hermesDesktop.api<ToolsetModelsResponse>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}`
})
}
export function selectToolsetModel(
name: string,
model: string,
provider?: string,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`,
method: 'PUT',
body: { model, provider }
})
}
export interface SelectToolsetProviderResponse {
ok: boolean
name: string
provider: string
/** Present when the selection was scoped to one web capability. */
capability?: string
/** Present (true) when a managed Nous row was selected but the Portal
* entitlement is missing — the row won't activate until the user signs
* in to Nous Portal. */
needs_nous_auth?: boolean
/** The managed feature key (e.g. "browser") when needs_nous_auth is set. */
feature?: string
}
export function selectToolsetProvider(
name: string,
provider: string,
capability?: 'search' | 'extract',
profile?: ProfileScope
): Promise<SelectToolsetProviderResponse> {
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
body: capability ? { provider, capability } : { provider }
})
}
export function runToolsetPostSetup(
name: string,
key: string,
profile?: ProfileScope
): Promise<ActionResponse & { key: string }> {
return window.hermesDesktop.api<ActionResponse & { key: string }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
method: 'POST',
body: { key }
})
}
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
return hermesApi<TerminalBackendsResponse>({
...profileScoped(),
path: '/api/tools/terminal/backends'
})
}
export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> {
return hermesApi<{ ok: boolean; backend: string }>({
...profileScoped(),
path: '/api/tools/terminal/backend',
method: 'PUT',
body: { backend }
})
}
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
return hermesApi<ComputerUseStatus>({
...profileScoped(),
path: '/api/tools/computer-use/status'
})
}
export function grantComputerUsePermissions(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/tools/computer-use/permissions/grant',
method: 'POST'
})
}
+61 -8
View File
@@ -67,6 +67,8 @@ const cssEscape = (value: string): string => {
}
interface SubmitDetail {
/** Unique mounted composer surface captured at click time. */
surfaceId: string
target: ComposerTarget
text: string
/** `hidden` types the persisted user row so no bubble renders — the
@@ -155,6 +157,38 @@ const dispatch = <T>(name: string, detail: T) => {
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
/** Submit is the one bus mutation that must preserve the chat visible at click
* time. Deferring it lets a parent click handler/tab reveal switch the active
* keep-alive pane before subscribers run, so the task is dropped or claimed by
* another composer. Other bus events intentionally defer for focus restoration.
*/
const dispatchNow = <T>(name: string, detail: T) => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<T>(name, { detail }))
}
}
/** Unique identity for the visible composer surface addressed by a submit. */
const getVisibleComposerSurfaceId = (target: ComposerTarget): string | null => {
if (typeof document === 'undefined') {
return null
}
const surface = queryVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`)
return surface?.dataset.composerSurfaceId || null
}
const composerSurfaceIsVisible = (target: ComposerTarget, surfaceId: string): boolean => {
if (typeof document === 'undefined') {
return false
}
return queryAllVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`).some(
surface => surface.dataset.composerSurfaceId === surfaceId
)
}
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
@@ -264,17 +298,36 @@ export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail)
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active', displayKind }: { target?: ComposerTarget | 'active'; displayKind?: 'hidden' } = {}
) => {
{
displayKind,
surfaceId: requestedSurfaceId,
target = 'active'
}: { displayKind?: 'hidden'; surfaceId?: null | string; target?: ComposerTarget | 'active' } = {}
): boolean => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, {
target: resolve(target),
text: trimmed,
...(displayKind ? { displayKind } : {})
})
if (!trimmed) {
return false
}
const resolvedTarget = resolve(target)
const surfaceId = requestedSurfaceId === undefined ? getVisibleComposerSurfaceId(resolvedTarget) : requestedSurfaceId
// Fail closed: without an exact visible surface identity, broadcasting a
// submit could make more than one keep-alive/new-chat composer claim it.
if (!surfaceId || (requestedSurfaceId !== undefined && !composerSurfaceIsVisible(resolvedTarget, surfaceId))) {
return false
}
dispatchNow<SubmitDetail>(SUBMIT_EVENT, {
surfaceId,
target: resolvedTarget,
text: trimmed,
...(displayKind ? { displayKind } : {})
})
return true
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
@@ -1,6 +1,8 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { type Dispatch, type PropsWithChildren, type SetStateAction, useLayoutEffect, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { $gateway } from '@/store/gateway'
@@ -12,23 +14,41 @@ import {
setSudoRequest
} from '@/store/prompts'
import { type ComposerTarget, requestComposerSubmit } from '../focus'
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
compacting?: boolean
inputDisabled?: boolean
scopeTarget?: ComposerTarget
sessionKey?: string | null
submitOnHide?: boolean
surfaceId?: string | null
text?: string
visible?: boolean
}
let surfaceSequence = 0
function renderSubmitHook({
attachments = [],
busy = false,
compacting = false,
text = ''
inputDisabled = false,
scopeTarget = 'main',
sessionKey = 'stored-session',
submitOnHide = false,
surfaceId,
text = '',
visible = true
}: SubmitHarnessOptions = {}) {
const resolvedSurfaceId = surfaceId === undefined ? `test-surface-${++surfaceSequence}` : surfaceId
const draftRef = { current: text }
const editor = document.createElement('div')
const editor = window.document.createElement('div')
editor.dataset.slot = 'composer-rich-input'
editor.textContent = text
const editorRef = { current: editor }
@@ -36,43 +56,214 @@ function renderSubmitHook({
const onSteer = vi.fn(async () => true)
const onSubmit = vi.fn(async () => true)
const queueCurrentDraft = vi.fn(() => true)
let updatePaneVisible: Dispatch<SetStateAction<boolean>> | undefined
const clearDraft = vi.fn(() => {
draftRef.current = ''
editorRef.current!.textContent = ''
})
const hook = renderHook(() =>
useComposerSubmit({
activeQueueSessionKey: 'stored-session',
activeQueueSessionKeyRef: { current: 'stored-session' },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled: false,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
})
const Wrapper = ({ children }: PropsWithChildren) => {
const [paneVisible, setPaneVisible] = useState(visible)
updatePaneVisible = setPaneVisible
useLayoutEffect(() => {
if (submitOnHide && !paneVisible) {
requestComposerSubmit('ship while hiding', { target: scopeTarget })
}
}, [paneVisible])
return (
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, target: scopeTarget }}>
<ComposerSurfaceProvider value={resolvedSurfaceId}>
<PaneVisibleContext.Provider value={paneVisible}>
<div
data-composer-surface-id={resolvedSurfaceId ?? undefined}
data-composer-target={scopeTarget}
data-pane-hidden={paneVisible ? undefined : ''}
>
{children}
</div>
</PaneVisibleContext.Provider>
</ComposerSurfaceProvider>
</ComposerScopeProvider>
)
}
const hook = renderHook(
() =>
useComposerSubmit({
activeQueueSessionKey: sessionKey,
activeQueueSessionKeyRef: { current: sessionKey },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
}),
{ wrapper: Wrapper }
)
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
return {
clearDraft,
hook,
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
composerSurfaceId: resolvedSurfaceId,
setPaneVisible(nextVisible: boolean) {
if (!updatePaneVisible) {
throw new Error('Pane visibility setter was not initialized')
}
updatePaneVisible(nextVisible)
}
}
}
describe('useComposerSubmit external request routing', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('does not fan out a main ship across keep-alives or other projects', async () => {
const visibleMain = renderSubmitHook({ sessionKey: 'session-a' })
const hiddenMain = renderSubmitHook({ sessionKey: 'session-b', visible: false })
const visibleTile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
const hiddenTile = renderSubmitHook({
scopeTarget: 'tile:project-c',
sessionKey: 'other-tile',
visible: false
})
expect(requestComposerSubmit('ship this branch', { target: 'main' })).toBe(true)
await waitFor(() =>
expect(visibleMain.onSubmit).toHaveBeenCalledWith('ship this branch', {
composerScope: 'session-a'
})
)
expect(visibleMain.onSubmit).toHaveBeenCalledTimes(1)
expect(hiddenMain.onSubmit).not.toHaveBeenCalled()
expect(visibleTile.onSubmit).not.toHaveBeenCalled()
expect(hiddenTile.onSubmit).not.toHaveBeenCalled()
})
it('routes a tile-targeted submit to that tile only', async () => {
const main = renderSubmitHook({ sessionKey: 'main-session' })
const tile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
expect(requestComposerSubmit('ship project B', { target: 'tile:project-b' })).toBe(true)
await waitFor(() =>
expect(tile.onSubmit).toHaveBeenCalledWith('ship project B', {
composerScope: 'tile-session'
})
)
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('uses the captured surface id when two visible composers share a target', async () => {
const first = renderSubmitHook({ sessionKey: 'session-first' })
const second = renderSubmitHook({ sessionKey: 'session-second' })
requestComposerSubmit('ship exactly one session', { surfaceId: second.composerSurfaceId, target: 'main' })
await waitFor(() =>
expect(second.onSubmit).toHaveBeenCalledWith('ship exactly one session', {
composerScope: 'session-second'
})
)
expect(first.onSubmit).not.toHaveBeenCalled()
})
it('submits to the session visible at click time even when the same click switches tabs', async () => {
const hiddenA = renderSubmitHook({ sessionKey: 'session-a', visible: false })
const visibleB = renderSubmitHook({ sessionKey: 'session-b' })
act(() => {
requestComposerSubmit('ship session B', { target: 'main' })
visibleB.setPaneVisible(false)
hiddenA.setPaneVisible(true)
})
await waitFor(() =>
expect(visibleB.onSubmit).toHaveBeenCalledWith('ship session B', {
composerScope: 'session-b'
})
)
expect(hiddenA.onSubmit).not.toHaveBeenCalled()
})
it('does not fan out when visible composers do not have queue session keys yet', async () => {
const firstNewSession = renderSubmitHook({ sessionKey: null })
const secondNewSession = renderSubmitHook({ sessionKey: null })
act(() => {
requestComposerSubmit('ship the visible new session', { target: 'main' })
})
await waitFor(() => expect(firstNewSession.onSubmit).toHaveBeenCalledTimes(1))
expect(secondNewSession.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when the visible composer has no surface identity', () => {
const unidentified = renderSubmitHook({ surfaceId: null })
expect(requestComposerSubmit('do not broadcast this', { target: 'main' })).toBe(false)
expect(unidentified.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when a pinned origin surface is no longer visible', () => {
const hidden = renderSubmitHook({ sessionKey: 'hidden-origin', visible: false })
const visible = renderSubmitHook({ sessionKey: 'other-visible' })
expect(
requestComposerSubmit('do not send to a stale origin', {
surfaceId: hidden.composerSurfaceId,
target: 'main'
})
).toBe(false)
expect(hidden.onSubmit).not.toHaveBeenCalled()
expect(visible.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a composer whose pane is hidden during the request', () => {
const main = renderSubmitHook({ submitOnHide: true })
act(() => main.setPaneVisible(false))
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a disabled composer', () => {
const disabled = renderSubmitHook({ inputDisabled: true })
requestComposerSubmit('do not send this', { target: 'main' })
expect(disabled.onSubmit).not.toHaveBeenCalled()
})
})
describe('useComposerSubmit busy-turn routing', () => {
afterEach(() => {
cleanup()
@@ -1,5 +1,6 @@
import { type RefObject, useEffect, useRef } from 'react'
import { type RefObject, useLayoutEffect, useRef } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
@@ -13,7 +14,7 @@ import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { pathifyRefs } from '../path-refs'
import { composerPlainText } from '../rich-editor'
import { useComposerScope } from '../scope'
import { useComposerScope, useComposerSurfaceId } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
@@ -76,7 +77,9 @@ export function useComposerSubmit({
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
const paneVisible = usePaneVisible()
const scope = useComposerScope()
const surfaceId = useComposerSurfaceId()
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
@@ -103,19 +106,26 @@ export function useComposerSubmit({
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
// button) route through the same send path. Match both the composer target
// and the exact visible surface captured at click time — every tile stays
// mounted, and a session can be rendered in more than one pane.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
useLayoutEffect(
() =>
onComposerSubmitRequest(({ target, text, displayKind }) => {
if (target === 'main' && !inputDisabled) {
onComposerSubmitRequest(({ surfaceId: requestedSurfaceId, target, text, displayKind }) => {
if (
target === scope.target &&
surfaceId !== null &&
requestedSurfaceId === surfaceId &&
paneVisible &&
!inputDisabled
) {
dispatchSubmitRef.current(text, undefined, displayKind)
}
}),
[inputDisabled]
[inputDisabled, paneVisible, scope.target, surfaceId]
)
const submitDraft = () => {
@@ -36,6 +36,26 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils
* prose off and stranded a partial `folder:` in front of the chip, because the
* window it removed wasn't the token the user was typing.
*/
/** The keyup half of trigger detection, shared by both composers.
*
* If the open popover already consumed this key in keydown (Arrow/Enter/Tab/
* Escape), skip the refresh: those keys never edit text, and for Escape the
* keydown already closed the menu — refreshing here would re-detect the
* still-present `/` and instantly reopen it. It reads a ref set during keydown
* rather than `trigger`, because by keyup time React has re-rendered and
* `trigger` may already be null. */
export function triggerKeyUpHandler(consumedRef: MutableRefObject<boolean>, refreshTrigger: () => void) {
return () => {
if (consumedRef.current) {
consumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
}
export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) {
const current = composerPlainText(editor)
const caret = caretOffsetInEditor(editor)
@@ -2,6 +2,8 @@ import { render } from '@testing-library/react'
import { createRef, type RefObject } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { placeCaretAtEnd } from '../test-utils'
import { useComposerUndo } from './use-composer-undo'
/** Mount the hook against a real contentEditable, exposing its API. */
@@ -36,19 +38,10 @@ function makeEditor(text: string) {
return { editor, ref }
}
const caretAtEnd = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
describe('useComposerUndo', () => {
it('restores the pre-edit text, which is what a paste destroyed', () => {
const { editor, ref } = makeEditor('before')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -69,7 +62,7 @@ describe('useComposerUndo', () => {
it('withUndoPoint banks only when the edit actually ran', () => {
const { editor, ref } = makeEditor('text')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -95,7 +88,7 @@ describe('useComposerUndo', () => {
it('claims a native historyUndo aimed at the focused editor', () => {
const { editor, ref } = makeEditor('kept')
editor.focus()
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -162,7 +155,7 @@ describe('useComposerUndo', () => {
it('reset drops history so undo cannot cross a draft swap', () => {
const { editor, ref } = makeEditor('session A')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
+3 -17
View File
@@ -50,7 +50,7 @@ import { useComposerPlaceholder } from './hooks/use-composer-placeholder'
import { useComposerPopout } from './hooks/use-composer-popout'
import { useComposerQueue } from './hooks/use-composer-queue'
import { useComposerSubmit } from './hooks/use-composer-submit'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { triggerKeyUpHandler, useComposerTrigger } from './hooks/use-composer-trigger'
import { useComposerUndo } from './hooks/use-composer-undo'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
import { useComposerVoice } from './hooks/use-composer-voice'
@@ -904,21 +904,7 @@ export function ChatBar({
}
}
const handleEditorKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
const handleEditorKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger)
const {
dragActive,
@@ -1278,7 +1264,7 @@ export function ChatBar({
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null))}
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
@@ -11,16 +11,7 @@ import {
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'
const caretIn = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
import { placeCaretAtEnd } from './test-utils'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
@@ -160,7 +151,7 @@ describe('insertInlineRefsIntoEditor', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review'))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
@@ -172,7 +163,7 @@ describe('insertInlineRefsIntoEditor', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review '))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
@@ -185,7 +176,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')
@@ -221,7 +212,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')
@@ -237,7 +228,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -253,7 +244,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
@@ -268,7 +259,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
@@ -283,7 +274,7 @@ describe('insertComposerContentsAtCaret', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -298,7 +289,7 @@ describe('insertComposerContentsAtCaret', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -43,3 +43,15 @@ const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
export const ComposerScopeProvider = ComposerScopeContext.Provider
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
/**
* Unique identity for one mounted ChatView/composer pair. Session ids cannot
* fill this role: a fresh chat has no id yet, and the same stored session can
* be rendered in more than one layout pane. External submit requests pin this
* surface id at click time so exactly one composer can claim the task.
*/
const ComposerSurfaceContext = createContext<string | null>(null)
export const ComposerSurfaceProvider = ComposerSurfaceContext.Provider
export const useComposerSurfaceId = (): string | null => useContext(ComposerSurfaceContext)
@@ -0,0 +1,14 @@
/** Collapse the selection to the end of the editor's contents.
*
* The composer's undo stack, ref rendering, and trigger detection all read the
* live selection, and jsdom starts with none — without this they see a caret
* at offset 0 and take the wrong branch. */
export function placeCaretAtEnd(editor: HTMLElement) {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
+15 -3
View File
@@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type { ReadableAtom } from 'nanostores'
import type * as React from 'react'
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
@@ -58,7 +58,7 @@ import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
@@ -338,7 +338,17 @@ function ChatRuntimeBoundary({
// Memoized: the tile caller (session-tile.tsx) and the contrib surface re-render
// on idle ticks unrelated to the chat; with stable callback props (hoisted to
// useCallback at the call sites) memo() lets the whole chat shell skip those.
export const ChatView = memo(function ChatView({
export const ChatView = memo(function ChatView(props: ChatViewProps) {
const composerSurfaceId = useId()
return (
<ComposerSurfaceProvider value={composerSurfaceId}>
<ChatViewContent {...props} />
</ComposerSurfaceProvider>
)
})
const ChatViewContent = memo(function ChatViewContent({
className,
gateway,
modelMenuContent,
@@ -373,6 +383,7 @@ export const ChatView = memo(function ChatView({
// atoms) or a tile's session slice — same component either way.
const view = useSessionView()
const composerScope = useComposerScope()
const composerSurfaceId = useComposerSurfaceId()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
@@ -576,6 +587,7 @@ export const ChatView = memo(function ChatView({
className
)}
data-chat-surface=""
data-composer-surface-id={composerSurfaceId}
data-composer-target={composerScope.target}
data-session-anchor={sessionAnchor}
>
@@ -13,6 +13,8 @@ import {
import { $connection, $sessions } from '@/store/session'
import { $sessionStates, type SessionTileDelegate, setSessionTileDelegate } from '@/store/session-states'
import { deferred } from '../../test/deferred'
const requestGateway = vi.fn()
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
@@ -44,16 +46,6 @@ const STAGED_PATH = '/root/.hermes/attachments/photo.png'
const THUMBNAIL = 'data:image/png;base64,dGh1bWJuYWls'
const FULL_SOURCE = 'data:image/png;base64,b3JpZ2luYWw='
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
function makeAttachment(occurrenceId = createComposerAttachmentOccurrenceId()): ComposerAttachment {
return {
detail: HOST_PATH,
+7 -4
View File
@@ -467,16 +467,19 @@ export function ChatSidebar({
// Profile scope = the "workspace switcher" context. Concrete scope shows only
// that profile's sessions (clean rows, no per-row tags); ALL fans every
// profile in, grouped by profile below. Single-profile users land here with
// scope === their only profile, so nothing is filtered out.
// profile in. Grouped rendering stays gated on `showAllProfiles` (multi-profile
// + ALL) so a single-profile user is never stranded in a grouped view with no
// rail — but the *data* still has to fan in when the persisted scope is ALL
// (Grouping → Profile). Filtering that pool against the `__all__` sentinel
// matches nothing and empties recents + pins.
// Archived rows are excluded from the sessions query, so Archived is a view of
// its own set rather than a filter over this one — a flat list of archived
// rows, no project tree, no date or status dividers.
const scopedSessions = useMemo(() => {
const pool = showArchived ? archivedSessions : sessions
return showAllProfiles ? pool : pool.filter(s => normalizeProfileKey(s.profile) === profileScope)
}, [sessions, archivedSessions, showArchived, showAllProfiles, profileScope])
return filterSessionsByProfileScope(pool, profileScope)
}, [sessions, archivedSessions, showArchived, profileScope])
// One predicate for the status/project filters, so the flat list and the
// project lanes narrow by the same rule. A project lane holds rows the loaded
@@ -27,4 +27,12 @@ describe('filterSessionsByProfileScope', () => {
expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows)
})
it('does not empty ALL scope when every row is one profile', () => {
// Grouping → Profile persists ALL even with one profile. Filtering
// against the `__all__` sentinel would empty recents and pins.
const rows = [row('a', 'default'), row('b', 'default'), row('c', 'default')]
expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows)
})
})
@@ -1,7 +1,13 @@
import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
/** Return the sessions visible in one sidebar profile scope, or the original unified list for All profiles. */
/**
* Sessions visible in one sidebar profile scope.
*
* ALL (`__all__`) returns the caller's list unchanged — including the
* single-profile case, where Grouping → Profile persists that sentinel
* while grouped rendering stays off. Never filter against `__all__`.
*/
export function filterSessionsByProfileScope(sessions: SessionInfo[], profileScope: string): SessionInfo[] {
if (profileScope === ALL_PROFILES) {
return sessions
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { HermesGitWorktree } from '@/global'
import { makeCwdSession } from '@/test/session-info'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
import {
@@ -22,29 +23,6 @@ import {
// covered by tests/tui_gateway/test_project_tree.py). This file only covers the
// thin render helpers the desktop still owns + the VISUAL worktree enhancer.
let nextId = 0
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
archived: false,
cwd,
ended_at: null,
id: `s${nextId++}`,
input_tokens: 0,
is_active: false,
last_active: 1_000,
message_count: 1,
model: 'claude',
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 1_000,
title: null,
tool_call_count: 0,
...overrides
}
}
const lane = (over: Partial<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): SidebarSessionGroup => ({
path: null,
sessions: [],
@@ -76,7 +54,7 @@ describe('kanbanWorktreeDir', () => {
describe('sortWorktreeGroups', () => {
it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => {
const at = (t: number) => [makeSession('/x', { last_active: t })]
const at = (t: number) => [makeCwdSession('/x', { last_active: t })]
const groups = [
lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }),
@@ -92,7 +70,7 @@ describe('sortWorktreeGroups', () => {
it('pins the live home checkout above trunk, even when it has no sessions yet', () => {
const groups = [
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }),
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeCwdSession('/x', { last_active: 999 })] }),
lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true })
]
@@ -150,7 +128,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
sessions: [makeCwdSession('/repo')]
})
]
}
@@ -176,7 +154,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
it('does not add a second "main" for a linked worktree checked out on main', () => {
const groups = [
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
lane({
id: '/repo::branch::main',
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeCwdSession('/repo')]
})
]
const discovered: HermesGitWorktree[] = [
@@ -216,14 +200,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
sessions: [makeCwdSession('/repo')]
}),
lane({
id: '/repo-ci',
label: 'hermes-agent-ci',
isMain: false,
path: '/repo-ci',
sessions: [makeSession('/repo-ci')]
sessions: [makeCwdSession('/repo-ci')]
})
]
}
@@ -256,7 +240,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'bb/attempts',
isMain: false,
path: '/repo/.worktrees/attempts',
sessions: [makeSession('/repo/.worktrees/attempts')]
sessions: [makeCwdSession('/repo/.worktrees/attempts')]
})
]
}
@@ -291,7 +275,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'bb/feature',
isMain: false,
path: '/repo-feature',
sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')]
sessions: [makeCwdSession('/repo-feature'), makeCwdSession('/repo-feature')]
})
]
}
@@ -313,7 +297,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
id: '/repo',
path: '/repo',
groups: [
lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] })
lane({
id: '/repo-ci',
label: 'repo-ci',
isMain: false,
path: '/repo-ci',
sessions: [makeCwdSession('/repo-ci')]
})
]
}
@@ -334,7 +324,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
sessions: [makeCwdSession('/repo')]
})
]
}
@@ -366,7 +356,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
sessions: [makeCwdSession('/repo')]
})
]
}
@@ -391,14 +381,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'a' })]
sessions: [makeCwdSession('/repo', { id: 'a' })]
}),
lane({
id: '/repo::branch::old',
label: 'old-feature',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo', { id: 'b' })]
sessions: [makeCwdSession('/repo', { id: 'b' })]
})
]
}
@@ -425,7 +415,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
label: 'main',
isMain: true,
path: '/repo',
sessions: [makeSession('/repo')]
sessions: [makeCwdSession('/repo')]
})
]
}
@@ -481,11 +471,11 @@ const homeNode = (sessions: SessionInfo[]): SidebarProjectTree =>
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
expect(liveSessionProjectId(makeCwdSession('/www/app'), [])).toBe('/www/app')
})
it('routes a session under an explicit project folder to that project', () => {
const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
const id = liveSessionProjectId(makeCwdSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
makeProject('p_app', ['/www/app'])
])
@@ -495,31 +485,31 @@ describe('liveSessionProjectId', () => {
it('anchors a cwd-less session on its git_repo_root (backend groups it there too)', () => {
// Older/imported rows carry only a repo root; the sidebar files them under
// the repo's project, so membership (and color) must resolve from the root.
expect(liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app')
expect(liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app')
expect(
liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])])
liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])])
).toBe('p_app')
})
it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => {
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
expect(liveSessionProjectId(makeCwdSession(null), [])).toBeNull()
// Kanban task worktree → folds into the kanban bucket, not a project preview.
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
expect(liveSessionProjectId(makeCwdSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
// Sibling worktree OUTSIDE the repo root → project can't be derived from the row.
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
expect(liveSessionProjectId(makeCwdSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
})
it('places an in-tree worktree session under its repo project (the root is in the path)', () => {
// "Convert a branch" / "new worktree" land at `<repoRoot>/.worktrees/<slug>`,
// so they belong to the same auto project as the repo root and must show in
// the overview at once, not wait for the next backend refresh.
expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
expect(liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
'/www/app'
)
})
it('routes an in-tree worktree session to the owning explicit project', () => {
const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [
const id = liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [
makeProject('p_app', ['/www/app'])
])
@@ -532,13 +522,13 @@ describe('liveSessionProjectId', () => {
// only the auto-project (repo root) fallback needs cwd-under-root
// confidence. Match via the repo root...
expect(
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
liveSessionProjectId(makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
makeProject('p_proj', ['/home/u/proj'])
])
).toBe('p_proj')
// ...and via the cwd.
expect(
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
liveSessionProjectId(makeCwdSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
makeProject('p_www', ['/www/elsewhere'])
])
).toBe('p_www')
@@ -547,13 +537,13 @@ describe('liveSessionProjectId', () => {
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
// The bug: a fresh Windows session drops into the overlay before the next
// backend refresh; case-sensitive matching missed its project until then.
const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])])
const id = liveSessionProjectId(makeCwdSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])])
expect(id).toBe('p_notes')
})
it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => {
const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [
const id = liveSessionProjectId(makeCwdSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [
makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj'])
])
@@ -562,7 +552,7 @@ describe('liveSessionProjectId', () => {
it('keeps POSIX cwd matching case-sensitive (no false project match)', () => {
// Distinct case on POSIX is a distinct path → falls back to its own auto id.
expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe(
expect(liveSessionProjectId(makeCwdSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe(
'/work/notes'
)
})
@@ -575,19 +565,19 @@ describe('sessionProjectColor', () => {
})
it('inherits the color of the explicit project the session belongs to', () => {
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
it('returns null when the owning project has no color set', () => {
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull()
})
it('colors a cwd-less session by its git_repo_root project (the grouped-but-grey fix)', () => {
const session = makeSession(null, { git_repo_root: '/www/app' })
const session = makeCwdSession(null, { git_repo_root: '/www/app' })
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
@@ -595,7 +585,7 @@ describe('sessionProjectColor', () => {
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
// The backend tree groups such a row under the project; the client color
// derivation must agree instead of leaving the row (and its tab) grey.
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
const session = makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
})
@@ -603,15 +593,15 @@ describe('sessionProjectColor', () => {
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
// liveSessionProjectId falls back to the repo root id, which is not a
// project row and therefore carries no color.
expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull()
expect(sessionProjectColor(makeCwdSession('/www/app'), [])).toBeNull()
})
it('returns null for an unplaceable (cwd-less) session', () => {
expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull()
expect(sessionProjectColor(makeCwdSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull()
})
it('uses the longest-prefix project when nested projects both match', () => {
const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' })
const session = makeCwdSession('/www/app/packages/api/src', { git_repo_root: '/www/app' })
const projects = [
colored('p_root', ['/www/app'], '#111111'),
@@ -630,7 +620,7 @@ describe('overlayLiveLanes', () => {
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })]
const live = [makeCwdSession('/www/app', { id: 'fresh', git_branch: 'main' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.label === 'main')
@@ -649,7 +639,7 @@ describe('overlayLiveLanes', () => {
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })]
const live = [makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby')
@@ -665,7 +655,7 @@ describe('overlayLiveLanes', () => {
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
})
const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
const live = [makeCwdSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
const overlaid = overlayLiveLanes(project, live)
const lane = overlaid.repos[0].groups.find(g => g.isKanban)
@@ -675,7 +665,7 @@ describe('overlayLiveLanes', () => {
})
it('does not duplicate a session already present in a backend lane', () => {
const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' })
const existing = makeCwdSession('/www/app', { id: 'dup', git_branch: 'main' })
const project = projectNode({
id: '/www/app',
@@ -704,8 +694,8 @@ describe('overlayLiveLanes', () => {
// and CREATE a second main lane with the same sessions — dual lanes in the
// project drill-in (e.g. main + codex-research-guardian).
const root = '/home/hermes/hermes-workspace/codex-research-guardian'
const a = makeSession(root, { id: 's1' }) // empty git_branch / git_repo_root
const b = makeSession(root, { id: 's2' })
const a = makeCwdSession(root, { id: 's1' }) // empty git_branch / git_repo_root
const b = makeCwdSession(root, { id: 's2' })
const project = projectNode({
id: root,
@@ -742,7 +732,7 @@ describe('overlayLiveLanes', () => {
it('joins a fresh live session into an existing non-git workspace lane (no branch id)', () => {
const root = '/work/notes'
const existing = makeSession(root, { id: 'old' })
const existing = makeCwdSession(root, { id: 'old' })
const project = projectNode({
id: root,
@@ -759,7 +749,7 @@ describe('overlayLiveLanes', () => {
]
})
const fresh = makeSession(root, { id: 'fresh' })
const fresh = makeCwdSession(root, { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const groups = overlaid.repos[0].groups
@@ -769,14 +759,14 @@ describe('overlayLiveLanes', () => {
})
it('preserves backend recency order when live sessions overlay a lane', () => {
const recentlyActive = makeSession('/www/app', {
const recentlyActive = makeCwdSession('/www/app', {
id: 'recently-active',
git_branch: 'main',
started_at: 1,
last_active: 3
})
const newlyCreated = makeSession('/www/app', {
const newlyCreated = makeCwdSession('/www/app', {
id: 'newly-created',
git_branch: 'main',
started_at: 2,
@@ -816,7 +806,7 @@ describe('overlayLiveLanes', () => {
// Backend keyed the worktree lane off a branch-style id (no live git probe),
// but the lane PATH is the worktree dir. A new session under that worktree
// must join the existing lane, not spawn a twin.
const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' })
const existing = makeCwdSession('/www/app/.worktrees/baby', { id: 'old' })
const project = projectNode({
id: '/www/app',
@@ -838,7 +828,7 @@ describe('overlayLiveLanes', () => {
]
})
const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' })
const fresh = makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby')
@@ -850,7 +840,7 @@ describe('overlayLiveLanes', () => {
it('places a session into an out-of-tree (sibling) worktree lane by its path', () => {
// `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under
// it — repo-root nesting fails, but the existing lane carries its real path.
const existing = makeSession('/www/app-ci', { id: 'old' })
const existing = makeCwdSession('/www/app-ci', { id: 'old' })
const project = projectNode({
id: '/www/app',
@@ -868,7 +858,7 @@ describe('overlayLiveLanes', () => {
]
})
const fresh = makeSession('/www/app-ci', { id: 'fresh' })
const fresh = makeCwdSession('/www/app-ci', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [existing, fresh])
const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci')
@@ -890,7 +880,7 @@ describe('overlayLiveLanes', () => {
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }]
})
const fresh = makeSession('/www/app-retry', { id: 'fresh' })
const fresh = makeCwdSession('/www/app-retry', { id: 'fresh' })
const overlaid = overlayLiveLanes(project, [fresh])
const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry')
@@ -899,8 +889,8 @@ describe('overlayLiveLanes', () => {
})
it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => {
const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' })
const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' })
const a = makeCwdSession('/www/app', { id: 'keep', git_branch: 'main' })
const b = makeCwdSession('/www/app/.worktrees/baby', { id: 'gone' })
const project = projectNode({
id: '/www/app',
@@ -927,11 +917,11 @@ describe('overlayLiveLanes', () => {
})
it('adds a brand-new detached chat to Home, and evicts a deleted one', () => {
const existing = makeSession(null, { id: 'old', started_at: 1 })
const doomed = makeSession(null, { id: 'gone', started_at: 2 })
const existing = makeCwdSession(null, { id: 'old', started_at: 1 })
const doomed = makeCwdSession(null, { id: 'gone', started_at: 2 })
const home = homeNode([existing, doomed])
const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone']))
const overlaid = overlayLiveLanes(home, [makeCwdSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone']))
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old'])
expect(overlaid.sessionCount).toBe(2)
@@ -943,14 +933,14 @@ describe('overlayLiveLanes', () => {
// and back out on the next snapshot.
const home = homeNode([])
expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home)
expect(overlayLiveLanes(home, [makeCwdSession('/www/app', { id: 'fresh' })])).toBe(home)
})
it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => {
// Session was in main when the backend tree was captured, but the live
// $sessions cache now has it under a worktree cwd. The overlay must place
// it ONLY in the worktree lane — not both.
const session = makeSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' })
const session = makeCwdSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' })
const project = projectNode({
id: '/www/app',
@@ -989,10 +979,10 @@ describe('overlayLivePreviews', () => {
it('merges live sessions into a project preview, live first, capped to the limit', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
previewSessions: [makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
})
const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
const live = [makeCwdSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
const previews = overlayLivePreviews([project], live, [], 3)
@@ -1003,8 +993,8 @@ describe('overlayLivePreviews', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
makeCwdSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
]
})
@@ -1017,9 +1007,9 @@ describe('overlayLivePreviews', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [
makeSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }),
makeSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }),
makeSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 })
makeCwdSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }),
makeCwdSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }),
makeCwdSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 })
]
})
@@ -1029,7 +1019,7 @@ describe('overlayLivePreviews', () => {
})
it('previews a detached session under Home, which no cwd could place', () => {
const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3)
const previews = overlayLivePreviews([homeNode([])], [makeCwdSession(null, { id: 'fresh' })], [], 3)
expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh'])
})
@@ -1037,8 +1027,8 @@ describe('overlayLivePreviews', () => {
describe('excludeProjectSessions', () => {
it('drops matching rows from every lane and recounts the subtree', () => {
const keep = makeSession('/www/app', { id: 'keep' })
const pinnedRow = makeSession('/www/app', { id: 'pinned' })
const keep = makeCwdSession('/www/app', { id: 'keep' })
const pinnedRow = makeCwdSession('/www/app', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
@@ -1062,7 +1052,7 @@ describe('excludeProjectSessions', () => {
})
it('keeps a lane the filter emptied — a worktree is structure, not a row', () => {
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
@@ -1090,7 +1080,7 @@ describe('excludeProjectSessions', () => {
it('returns the same node when nothing matches (memo-stable)', () => {
const project = projectNode({
id: '/www/app',
previewSessions: [makeSession('/www/app', { id: 'keep' })],
previewSessions: [makeCwdSession('/www/app', { id: 'keep' })],
repos: [
{
id: '/www/app',
@@ -1098,7 +1088,7 @@ describe('excludeProjectSessions', () => {
path: '/www/app',
sessionCount: 1,
groups: [
lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] })
lane({ id: 'main', isMain: true, label: 'main', sessions: [makeCwdSession('/www/app', { id: 'keep' })] })
]
}
],
@@ -1111,7 +1101,7 @@ describe('excludeProjectSessions', () => {
it('survives the live overlay: a lane left empty by the filter is not pruned', () => {
// The two run in sequence on an entered project (filter, then overlay), and
// the overlay drops lanes it empties — it must not take the filter's with it.
const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' })
const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' })
const project = projectNode({
id: '/www/app',
@@ -9,18 +9,12 @@ import { render } from '@testing-library/react'
import { Command } from 'cmdk'
import { describe, expect, it, vi } from 'vitest'
import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom'
import { HighlightWatcher } from './highlight-watcher'
// cmdk observes group headings with a ResizeObserver, which jsdom lacks.
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.scrollIntoView = function scrollIntoView() {}
stubResizeObserver()
stubMenuDomApis()
const palette = (onValue: (value: string) => void, onRootValueChange?: (value: string) => void) => (
<Command onValueChange={onRootValueChange}>
@@ -10,9 +10,11 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { StatusRow } from '@/app/command-palette/status-row'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import { useDebounced } from '@/app/hooks/use-debounced'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -23,18 +25,6 @@ import { $marketplaceInstalls } from '@/themes/user-themes'
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}
interface MarketplaceThemePageProps {
search: string
/** Activate a freshly installed theme by slug. */
@@ -90,17 +80,17 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
}
if (query.isLoading) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (query.isError) {
return <Status text={copy.error} tone="error" />
return <StatusRow text={copy.error} tone="error" />
}
const results = query.data ?? []
if (results.length === 0) {
return <Status text={copy.empty} />
return <StatusRow text={copy.empty} />
}
return (
@@ -156,17 +146,3 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
</div>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
@@ -10,6 +10,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { StatusRow } from '@/app/command-palette/status-row'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
@@ -59,15 +60,15 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
}
if (status === 'loading' && !gallery) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (status === 'stale') {
return <Status text={copy.staleBackend} tone="error" />
return <StatusRow text={copy.staleBackend} tone="error" />
}
if (!gallery?.pets.length && error) {
return <Status text={error} tone="error" />
return <StatusRow text={error} tone="error" />
}
const mutating = Boolean(busy)
@@ -95,7 +96,7 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
<Status text={copy.empty} />
<StatusRow text={copy.empty} />
) : (
shown.map(pet => {
const isActive = enabled && pet.slug === active
@@ -198,17 +199,3 @@ export function PetInlineToggle() {
</button>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
@@ -0,0 +1,18 @@
import type React from 'react'
import { cn } from '@/lib/utils'
/** Centered loading / empty / error row for a Cmd-K page's result list. */
export function StatusRow({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}
+9 -2
View File
@@ -59,7 +59,14 @@ import {
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review'
import {
$reviewOpen,
$reviewScopeCwd,
$reviewScopeTarget,
closeReview,
openReview,
REVIEW_PANE_ID
} from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { watchUnreadWriteGuard } from '@/store/session-unread-remote'
@@ -580,7 +587,7 @@ bindPaneVisibility(
'review',
computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace),
closeReview,
openReview
() => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get())
)
// ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not
// hides; PTYs stay alive while collapsed (see PersistentTerminal).
@@ -2,7 +2,7 @@
// usage bands. Each trigger emits ONE synthetic `notification.show` /
// `notification.clear` gateway event through the real fan-out
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
// `use-message-stream/gateway-event/status.ts` — toast render, key-replacement
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
//
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
@@ -6,6 +6,8 @@ import { _resetLegacyDiscardForTests } from '@/store/session'
import type * as WindowsStore from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
import { makeSessionInfo } from '../../../test/session-info'
import { useDesktopIntegrations } from './use-desktop-integrations'
// Mutable HUD-window flag so the restore tests can flip the window kind the
@@ -36,24 +38,7 @@ vi.mock('@/store/windows', async importOriginal => {
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
const session = (over: Partial<SessionInfo> = {}): SessionInfo => ({
archived: false,
cwd: null,
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: null,
tool_call_count: 0,
...over
})
const session = (over: Partial<SessionInfo> = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over })
describe('useDesktopIntegrations', () => {
let navigate: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
@@ -16,19 +16,12 @@ import { getHermesConfigRecord, saveMcpServers } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink'
import { getServers } from '@/lib/mcp-servers'
import { $mcpInstallRequest } from '@/store/mcp-deeplink-install'
import { notify, readableError } from '@/store/notifications'
import { setHermesConfigCache } from '../hooks/use-config-record'
type McpServers = Record<string, Record<string, unknown>>
const getServers = (config: { mcp_servers?: unknown } | null): McpServers => {
const raw = config?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
}
/**
* Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is
* arbitrary attacker-controllable input (any web page can open the link), so
+2 -10
View File
@@ -12,18 +12,10 @@ vi.mock('@/hermes', () => ({
import { beginCronJobsRequest } from '@/store/cron'
import { deferred } from '../../test/deferred'
import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
describe('triggerAndRefreshCronJobs', () => {
beforeEach(() => {
getApiRequestConnection.mockReset()
@@ -15,6 +15,7 @@ import {
$reviewCommitDefault,
$reviewCommitMsgBusy,
$reviewFiles,
$reviewScopeTarget,
$reviewShipBusy,
$reviewShipInfo,
cancelCommitMessage,
@@ -35,6 +36,7 @@ export function ReviewShipBar() {
const c = t.statusStack.coding
const files = useStore($reviewFiles)
const ship = useStore($reviewShipInfo)
const scopeTarget = useStore($reviewScopeTarget)
const busy = useStore($reviewShipBusy)
const generating = useStore($reviewCommitMsgBusy)
const commitDefault = useStore($reviewCommitDefault)
@@ -129,7 +131,11 @@ export function ReviewShipBar() {
<Button
className="min-w-0 flex-1 justify-center px-7 text-[0.7rem] text-muted-foreground/85 hover:text-foreground"
disabled={!hasFiles}
onClick={() => requestComposerSubmit(c.agentShipPrompt, { target: 'main' })}
onClick={() => {
if (!requestComposerSubmit(c.agentShipPrompt, { target: scopeTarget })) {
notifyError(new Error(c.agentShipUnavailable), c.agentShip)
}
}}
size="sm"
variant="ghost"
>
@@ -1,10 +1,11 @@
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { hiddenPaneProps, PANE_HIDDEN_ATTR } from '@/components/pane-shell/pane-visibility'
import { $paneStates } from '@/store/panes'
import { reactRoot } from '@/test/react-root'
import { installWindowStateBridge, setDocumentHidden, type WindowStateBridge } from '../../../test/window-state'
import { $terminalTakeover } from '../store'
import { PersistentTerminal, TerminalSlot } from './persistent'
@@ -24,54 +25,8 @@ vi.mock('./workspace', () => ({
let resizeObserverCallback: ResizeObserverCallback | null = null
let mutationObserverCallback: MutationCallback | null = null
let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = []
let root: Root | null = null
let container: HTMLDivElement | null = null
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
function render(ui: ReactNode) {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() => {
root!.render(ui)
})
}
function cleanup() {
if (root) {
act(() => {
root!.unmount()
})
}
container?.remove()
root = null
container = null
}
function setVisibility(hidden: boolean) {
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
}
function installWindowStateBridge() {
windowStateCallback = null
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
windowStateCallback = callback
return () => {
if (windowStateCallback === callback) {
windowStateCallback = null
}
}
})
}
})
}
const mount = reactRoot()
let windowState: WindowStateBridge
function rect(top: number, left: number, width: number, height: number): DOMRect {
return {
@@ -146,9 +101,9 @@ function HiddenPaneHarness({ hidden }: { hidden: boolean }) {
describe('PersistentTerminal rect tracking', () => {
beforeEach(() => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
setVisibility(false)
setDocumentHidden(false)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
installWindowStateBridge()
windowState = installWindowStateBridge()
resizeObserverCallback = null
mutationObserverCallback = null
mutationObserveCalls = []
@@ -181,11 +136,11 @@ describe('PersistentTerminal rect tracking', () => {
})
afterEach(() => {
cleanup()
mount.unmount()
$terminalTakeover.set(false)
vi.unstubAllGlobals()
vi.restoreAllMocks()
setVisibility(false)
setDocumentHidden(false)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -194,7 +149,7 @@ describe('PersistentTerminal rect tracking', () => {
let currentRect = rect(10, 20, 200, 100)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
render(<Harness />)
mount.render(<Harness />)
expect(raf.request).toHaveBeenCalledTimes(1)
@@ -231,7 +186,7 @@ describe('PersistentTerminal rect tracking', () => {
let currentRect = rect(10, 20, 200, 100)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
render(<Harness />)
mount.render(<Harness />)
expect(mutationObserveCalls.length).toBeGreaterThan(0)
expect(mutationObserveCalls.every(call => call.options?.subtree === false)).toBe(true)
@@ -240,7 +195,7 @@ describe('PersistentTerminal rect tracking', () => {
raf.runNext()
})
const overlay = container!.lastElementChild as HTMLElement
const overlay = mount.container!.lastElementChild as HTMLElement
expect(overlay.style.top).toBe('10px')
expect(overlay.style.left).toBe('20px')
expect(raf.pending()).toBe(0)
@@ -271,7 +226,7 @@ describe('PersistentTerminal rect tracking', () => {
const before = $paneStates.get()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
render(<Harness />)
mount.render(<Harness />)
raf.runNext()
expect(raf.pending()).toBe(0)
@@ -291,12 +246,12 @@ describe('PersistentTerminal rect tracking', () => {
const raf = installRaf()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
render(<Harness />)
mount.render(<Harness />)
expect(raf.request).toHaveBeenCalledTimes(1)
act(() => {
windowStateCallback?.({ isMinimized: true, isVisible: false })
windowState.emit({ isMinimized: true, isVisible: false })
})
expect(raf.cancel).toHaveBeenCalledTimes(1)
@@ -309,7 +264,7 @@ describe('PersistentTerminal rect tracking', () => {
expect(raf.request).toHaveBeenCalledTimes(1)
act(() => {
windowStateCallback?.({ isMinimized: false, isVisible: true })
windowState.emit({ isMinimized: false, isVisible: true })
})
expect(raf.request).toHaveBeenCalledTimes(2)
@@ -319,7 +274,7 @@ describe('PersistentTerminal rect tracking', () => {
const raf = installRaf()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
render(<Harness />)
mount.render(<Harness />)
expect(raf.pending()).toBe(1)
act(() => window.dispatchEvent(new Event('blur')))
@@ -328,7 +283,7 @@ describe('PersistentTerminal rect tracking', () => {
act(() => window.dispatchEvent(new Event('focus')))
expect(raf.pending()).toBe(1)
cleanup()
mount.unmount()
expect(raf.pending()).toBe(0)
act(() => {
@@ -344,7 +299,7 @@ describe('PersistentTerminal rect tracking', () => {
vi.mocked(document.hasFocus).mockReturnValue(false)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
render(<Harness />)
mount.render(<Harness />)
expect(raf.request).not.toHaveBeenCalled()
@@ -358,10 +313,10 @@ describe('PersistentTerminal rect tracking', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
$terminalTakeover.set(true)
render(<HiddenPaneHarness hidden={false} />)
mount.render(<HiddenPaneHarness hidden={false} />)
const overlay = container!.lastElementChild as HTMLElement
const workspace = container!.querySelector('[data-testid="terminal-workspace"]')
const overlay = mount.container!.lastElementChild as HTMLElement
const workspace = mount.container!.querySelector('[data-testid="terminal-workspace"]')
expect(overlay.style.visibility).toBe('visible')
expect(overlay.style.opacity).toBe('1')
@@ -377,7 +332,7 @@ describe('PersistentTerminal rect tracking', () => {
expect(raf.pending()).toBe(0)
act(() => {
root!.render(<HiddenPaneHarness hidden />)
mount.root!.render(<HiddenPaneHarness hidden />)
mutationObserverCallback?.([], {} as MutationObserver)
})
expect(raf.pending()).toBe(1)
@@ -389,7 +344,7 @@ describe('PersistentTerminal rect tracking', () => {
expect(overlay.style.visibility).toBe('hidden')
expect(overlay.style.opacity).toBe('0')
expect(overlay.style.pointerEvents).toBe('none')
expect(container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
act(() => {
raf.runNext()
@@ -397,7 +352,7 @@ describe('PersistentTerminal rect tracking', () => {
expect(raf.pending()).toBe(0)
act(() => {
root!.render(<HiddenPaneHarness hidden={false} />)
mount.root!.render(<HiddenPaneHarness hidden={false} />)
mutationObserverCallback?.([], {} as MutationObserver)
})
@@ -408,6 +363,6 @@ describe('PersistentTerminal rect tracking', () => {
expect(overlay.style.visibility).toBe('visible')
expect(overlay.style.opacity).toBe('1')
expect(overlay.style.pointerEvents).toBe('auto')
expect(container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
})
})
@@ -13,20 +13,12 @@ import {
setNewChatWorkspaceTarget
} from '@/store/session'
import { deferred } from '../../../test/deferred'
import { useCwdActions } from './use-cwd-actions'
type CwdActionsHandle = ReturnType<typeof useCwdActions>
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
function Harness({
activeSessionIdRef,
onReady,
@@ -18,6 +18,8 @@ import {
setDefaultReasoningEffort
} from '@/store/session'
import { deferred } from '../../../test/deferred'
import { useHermesConfig } from './use-hermes-config'
vi.mock('@/hermes', () => ({
@@ -27,16 +29,6 @@ vi.mock('@/hermes', () => ({
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
const mockConfig = (config: Record<string, unknown>) =>
vi.mocked(getHermesConfig).mockResolvedValue(config as Awaited<ReturnType<typeof getHermesConfig>>)
@@ -1,59 +1,24 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { textPart } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $notifications, clearNotifications } from '@/store/notifications'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'rt-new-session'
let handleEvent: ((event: RpcEvent) => void) | null = null
let sessionStates: Map<string, ClientSessionState> | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
sessionStates = sessionStateByRuntimeIdRef.current
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
/** Seed the session as it looks right after a first-message submit: the
* optimistic user row is present, the turn is awaiting its response. */
function seedOptimisticFirstMessage() {
sessionStates!.set(SID, {
stream.states.set(SID, {
...createClientSessionState('stored-new-session', [
{ id: 'user-123-abc', role: 'user', parts: [textPart('first message of a new chat')] }
]),
@@ -64,8 +29,6 @@ function seedOptimisticFirstMessage() {
describe('useMessageStream agent-init error surfacing (#63078)', () => {
beforeEach(() => {
handleEvent = null
sessionStates = null
clearNotifications()
})
@@ -76,11 +39,11 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
})
it('renders an agent-init failure as a visible in-transcript error and keeps the optimistic first message', async () => {
await mountStream()
mountStream()
seedOptimisticFirstMessage()
act(() =>
handleEvent!({
stream.handleEvent({
payload: {
message:
'agent initialization timed out after 601s — your message was not sent; retry once the session is ready'
@@ -90,7 +53,7 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
})
)
const state = sessionStates!.get(SID)!
const state = stream.state()
// The user's optimistic first message must survive — the failure mode of
// #63078 was the message silently vanishing into a blank session.
@@ -111,19 +74,19 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
expect($notifications.get().some(n => n.kind === 'error' && n.message?.includes('was not sent'))).toBe(true)
})
it('renders the pre-ready cancel error event (#65567 server emit) visibly', async () => {
await mountStream()
it('renders the pre-ready cancel error event (#65567 server emit) visibly', () => {
mountStream()
seedOptimisticFirstMessage()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { message: 'Turn cancelled before the agent was ready' },
session_id: SID,
type: 'error'
})
)
const state = sessionStates!.get(SID)!
const state = stream.state()
expect(state.messages.some(m => m.role === 'assistant' && m.error?.includes('cancelled'))).toBe(true)
expect(state.messages.some(m => m.id === 'user-123-abc')).toBe(true)
expect(state.busy).toBe(false)
@@ -1,55 +1,20 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
import { $activeGatewayProfile } from '@/store/profile'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const ACTIVE_SID = 'session-active'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(ACTIVE_SID)
}
describe('live session.info approval mode reconciliation', () => {
beforeEach(() => {
handleEvent = null
$approvalModes.set({})
$activeGatewayProfile.set('work')
})
@@ -59,11 +24,11 @@ describe('live session.info approval mode reconciliation', () => {
vi.restoreAllMocks()
})
it('reconciles an active-session event under its source gateway profile', async () => {
await mountStream()
it('reconciles an active-session event under its source gateway profile', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { approval_mode: 'off' },
profile: 'work',
session_id: ACTIVE_SID,
@@ -75,11 +40,11 @@ describe('live session.info approval mode reconciliation', () => {
expect(approvalModeForProfile('default')).toBe('smart')
})
it('ignores stale session.info from a non-active session on the active gateway', async () => {
await mountStream()
it('ignores stale session.info from a non-active session on the active gateway', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { approval_mode: 'off' },
profile: 'work',
session_id: 'session-stale',
@@ -90,11 +55,11 @@ describe('live session.info approval mode reconciliation', () => {
expect(approvalModeForProfile('work')).toBe('smart')
})
it('does not cache an event under a different active profile when its source profile is absent', async () => {
await mountStream()
it('does not cache an event under a different active profile when its source profile is absent', () => {
mountStream()
$activeGatewayProfile.set('personal')
act(() => handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
act(() => stream.handleEvent({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
expect(approvalModeForProfile('personal')).toBe('smart')
expect(approvalModeForProfile('work')).toBe('smart')
@@ -1,15 +1,10 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { type MutableRefObject, useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
import { onScrollToBottomRequest } from '@/store/thread-scroll'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
// A `clarify.request` must leave an answerable inline row even when the
// `tool.start` that normally mounts it was missed (stream reconnect /
@@ -18,62 +13,29 @@ import { useMessageStream } from './index'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
let stream: MessageStreamHarness
let stopScrollListener: (() => void) | null = null
const scrollToBottom = vi.fn()
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
stateRef = sessionStateByRuntimeIdRef
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
const clarifyRequest = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
act(() => stream.handleEvent({ payload, session_id: SID, type: 'clarify.request' }))
const toolStart = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.start' }))
function clarifyParts() {
const messages = stateRef?.current.get(SID)?.messages ?? []
const messages = stream.state().messages ?? []
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
}
describe('clarify.request stream hydration', () => {
beforeEach(() => {
handleEvent = null
stateRef = null
clearClarifyRequest()
scrollToBottom.mockClear()
stopScrollListener = onScrollToBottomRequest(scrollToBottom)
@@ -87,8 +49,8 @@ describe('clarify.request stream hydration', () => {
vi.restoreAllMocks()
})
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
await mountStream()
it('mounts an answerable clarify row when the tool.start row was missed', () => {
mountStream()
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
@@ -101,19 +63,19 @@ describe('clarify.request stream hydration', () => {
})
})
it('reveals a clarify prompt raised by the active session', async () => {
await mountStream()
it('reveals a clarify prompt raised by the active session', () => {
mountStream()
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-reveal' })
expect(scrollToBottom).toHaveBeenCalledOnce()
})
it('does not move the active thread for a background session clarify', async () => {
await mountStream()
it('does not move the active thread for a background session clarify', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-background' },
session_id: 'session-background',
type: 'clarify.request'
@@ -123,8 +85,8 @@ describe('clarify.request stream hydration', () => {
expect(scrollToBottom).not.toHaveBeenCalled()
})
it('preserves multi-select through the store and hydrated tool row', async () => {
await mountStream()
it('preserves multi-select through the store and hydrated tool row', () => {
mountStream()
clarifyRequest({
choices: ['read', 'write'],
@@ -149,8 +111,8 @@ describe('clarify.request stream hydration', () => {
})
})
it('merges with the real tool.start row even though its id differs from the request id', async () => {
await mountStream()
it('merges with the real tool.start row even though its id differs from the request id', () => {
mountStream()
// Reality: tool.start carries the model's tool_call_id, clarify.request a
// separately-generated request_id. They must still collapse to ONE card
@@ -161,8 +123,8 @@ describe('clarify.request stream hydration', () => {
expect(clarifyParts()).toHaveLength(1)
})
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
await mountStream()
it('does not duplicate when clarify.request arrives before the tool.start row', () => {
mountStream()
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
@@ -170,8 +132,8 @@ describe('clarify.request stream hydration', () => {
expect(clarifyParts()).toHaveLength(1)
})
it('merges a BATCH tool.start row with its clarify.request (no top-level question)', async () => {
await mountStream()
it('merges a BATCH tool.start row with its clarify.request (no top-level question)', () => {
mountStream()
// The batch shape: tool args carry `questions`, no top-level `question`.
// The correlation key must come from the question list, or the two ids
@@ -190,10 +152,11 @@ describe('clarify.request stream hydration', () => {
})
expect(clarifyParts()).toHaveLength(1)
expect($clarifyRequests.get()[SID]?.questions).toHaveLength(2)
})
it('does not duplicate when the batch clarify.request arrives before tool.start', async () => {
await mountStream()
it('does not duplicate when the batch clarify.request arrives before tool.start', () => {
mountStream()
clarifyRequest({
questions: [
@@ -209,5 +172,6 @@ describe('clarify.request stream hydration', () => {
})
expect(clarifyParts()).toHaveLength(1)
expect($clarifyRequests.get()[SID]?.questions).toHaveLength(2)
})
})
@@ -1,59 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
const OTHER_SID = 'session-2'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
act(() => stream.handleEvent({ payload, session_id: SID, type }))
}
describe('useMessageStream compaction lifecycle', () => {
beforeEach(() => {
handleEvent = null
$compactingSessions.set({})
})
@@ -68,8 +34,8 @@ describe('useMessageStream compaction lifecycle', () => {
['thinking.delta', { text: 'still working' }],
['reasoning.delta', { text: 'thinking again' }],
['tool.start', { name: 'terminal', tool_id: 'tool-1' }]
] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {
await mountStream()
] as const)('clears the stale compaction phase when %s resumes the turn', (type, payload) => {
mountStream()
setSessionCompacting(OTHER_SID, true)
emit('status.update', { kind: 'compacting' })
@@ -80,8 +46,8 @@ describe('useMessageStream compaction lifecycle', () => {
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
})
it('clears the compaction phase on the structured completion edge', async () => {
await mountStream()
it('clears the compaction phase on the structured completion edge', () => {
mountStream()
setSessionCompacting(OTHER_SID, true)
emit('status.update', { kind: 'compacting' })
@@ -1,10 +1,6 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import {
$currentModel,
$currentProvider,
@@ -12,50 +8,17 @@ import {
setCurrentModelSource,
setCurrentProvider
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness({ activeSessionId }: { activeSessionId: string | null }) {
const sessionIdRef = useRef<string | null>(activeSessionId)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
sessionIdRef.current = activeSessionId
const stream = useMessageStream({
activeSessionIdRef: sessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream(activeSessionId: string | null) {
render(<Harness activeSessionId={activeSessionId} />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream(activeSessionId: string | null) {
stream = renderMessageStream(activeSessionId)
}
describe('session.info does not clobber composer model selection', () => {
beforeEach(() => {
handleEvent = null
setCurrentModel('deepseek-v4-flash')
setCurrentProvider('deepseek')
setCurrentModelSource('manual')
@@ -69,11 +32,11 @@ describe('session.info does not clobber composer model selection', () => {
setCurrentModelSource('')
})
it('keeps a sticky manual pick when a global session.info carries the profile default', async () => {
await mountStream(null)
it('keeps a sticky manual pick when a global session.info carries the profile default', () => {
mountStream(null)
act(() =>
handleEvent!({
stream.handleEvent({
payload: { model: 'deepseek-chat', provider: 'deepseek' },
type: 'session.info'
})
@@ -83,11 +46,11 @@ describe('session.info does not clobber composer model selection', () => {
expect($currentProvider.get()).toBe('deepseek')
})
it('keeps the composer pick when an unscoped session.info arrives with no live session', async () => {
await mountStream(null)
it('keeps the composer pick when an unscoped session.info arrives with no live session', () => {
mountStream(null)
act(() =>
handleEvent!({
stream.handleEvent({
payload: { cwd: '/tmp/project', model: 'deepseek-chat', provider: 'deepseek' },
type: 'session.info'
})
@@ -9,10 +9,12 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { useSessionStateCache } from '../use-session-state-cache'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
import { useMessageStream } from './index'
const SID = 'session-1'
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
let stream: MessageStreamHarness
let states: Map<string, ClientSessionState>
type UpdateSessionState = (
sessionId: string,
@@ -21,44 +23,17 @@ type UpdateSessionState = (
) => ClientSessionState
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(states)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState
})
useEffect(() => {
appendAssistantDelta = stream.appendAssistantDelta
}, [stream.appendAssistantDelta])
return null
}
/** The shared harness, but writing through a spy so the unmount test can count
* the writes that happen after teardown. */
function mountStream() {
render(<Harness />)
expect(appendAssistantDelta).not.toBeNull()
stream = renderMessageStream(SID, { states, updateSessionState })
}
function assistantText() {
const message = states.get(SID)?.messages.at(-1)
const part = message?.parts.at(-1)
return part?.type === 'text' ? part.text : ''
}
const assistantText = () => stream.text()
describe('useMessageStream delta flush scheduling', () => {
beforeEach(() => {
vi.useFakeTimers()
appendAssistantDelta = null
states = new Map()
updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
const next = updater(states.get(sessionId) ?? createClientSessionState())
@@ -81,7 +56,7 @@ describe('useMessageStream delta flush scheduling', () => {
it('flushes streaming text on a bounded timer while the window is unfocused', async () => {
mountStream()
act(() => appendAssistantDelta!(SID, 'still streaming'))
act(() => stream.appendDelta(SID, 'still streaming'))
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
expect(assistantText()).toBe('')
@@ -97,7 +72,7 @@ describe('useMessageStream delta flush scheduling', () => {
vi.mocked(performance.now).mockReturnValue(0)
mountStream()
act(() => appendAssistantDelta!(SID, 'caught up on focus'))
act(() => stream.appendDelta(SID, 'caught up on focus'))
expect(assistantText()).toBe('')
expect(vi.getTimerCount()).toBe(1)
@@ -120,7 +95,7 @@ describe('useMessageStream delta flush scheduling', () => {
})
mountStream()
act(() => appendAssistantDelta!(SID, 'focused without visibility change'))
act(() => stream.appendDelta(SID, 'focused without visibility change'))
expect(assistantText()).toBe('')
expect(vi.getTimerCount()).toBe(1)
@@ -133,7 +108,7 @@ describe('useMessageStream delta flush scheduling', () => {
vi.mocked(performance.now).mockReturnValue(0)
mountStream()
act(() => appendAssistantDelta!(SID, 'final delta'))
act(() => stream.appendDelta(SID, 'final delta'))
expect(vi.getTimerCount()).toBe(1)
cleanup()
@@ -164,7 +139,7 @@ describe('useMessageStream delta flush scheduling', () => {
mountStream()
act(() => appendAssistantDelta!(SID, 'first'))
act(() => stream.appendDelta(SID, 'first'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
@@ -177,7 +152,7 @@ describe('useMessageStream delta flush scheduling', () => {
now = 1100
act(() => rafCallbacks[0](1040))
act(() => appendAssistantDelta!(SID, 'second'))
act(() => stream.appendDelta(SID, 'second'))
await act(async () => {
await vi.advanceTimersByTimeAsync(79)
})
@@ -201,7 +176,7 @@ describe('useMessageStream delta flush scheduling', () => {
mountStream()
act(() => appendAssistantDelta!(SID, 'first'))
act(() => stream.appendDelta(SID, 'first'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
@@ -210,7 +185,7 @@ describe('useMessageStream delta flush scheduling', () => {
// 100ms later (well past the 33ms floor): the next flush is immediate.
now = 1100
act(() => appendAssistantDelta!(SID, 'second'))
act(() => stream.appendDelta(SID, 'second'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
@@ -230,14 +205,14 @@ describe('useMessageStream delta flush scheduling', () => {
mountStream()
act(() => appendAssistantDelta!(SID, 'a'))
act(() => stream.appendDelta(SID, 'a'))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
// A second flush starts before the first flush's frame lands.
now = 1010
act(() => appendAssistantDelta!(SID, 'b'))
act(() => stream.appendDelta(SID, 'b'))
await act(async () => {
await vi.advanceTimersByTimeAsync(23)
})
@@ -250,7 +225,7 @@ describe('useMessageStream delta flush scheduling', () => {
now = 1030
act(() => rafCallbacks[0](1000))
act(() => appendAssistantDelta!(SID, 'c'))
act(() => stream.appendDelta(SID, 'c'))
await act(async () => {
await vi.advanceTimersByTimeAsync(13)
})
@@ -267,6 +242,7 @@ describe('useMessageStream composed with the real useSessionStateCache', () => {
// measured frame cost includes the deferred $messages commit it adapts to.
let cache: ReturnType<typeof useSessionStateCache> | null = null
let published: ChatMessage[]
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
function ComposedHarness() {
const busyRef: MutableRefObject<boolean> = { current: false }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
import type { TourAction, TourStep } from '@/lib/tour'
import { $gateway } from '@/store/gateway'
import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus'
import { recordAgentReaction } from '@/store/reactions-local'
import { setMessages } from '@/store/session'
import type { GatewayEventContext } from './types'
/** Desktop-surface bridge events: read-back requests the agent blocks on
* (terminal/preview/window), agent terminal streaming, pane reveal, and
* message reactions. */
export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
const { event, payload, isActiveEvent } = ctx
if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
// immediately (Python blocks on the respond). Empty text = no live pane.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
const result = readActiveTerminal({ start, count })
void $gateway.get()?.request('terminal.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
}
return true
}
if (event.type === 'preview.read.request') {
// read_preview tool: serialize the active preview tab (a Browser
// webview's page text is async) and answer. Empty text = nothing open.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
void readActivePreview({ count, start }).then(result => {
void $gateway.get()?.request('preview.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
})
}
return true
}
if (event.type === 'window.read.request') {
// read_window_below tool: main owns native window enumeration, so ask
// it over IPC and answer. Empty text = unavailable (no bridge, or
// enumeration unsupported on this system e.g. Wayland).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const read = window.hermesDesktop?.readWindowBelow
const answer = (result: unknown) =>
$gateway.get()?.request('window.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
// .catch: ipcRenderer.invoke rejects on an older shell without the
// handler or a main-side throw — without an empty answer the tool
// would stall its full 30s timeout.
void Promise.resolve(read ? read() : null).then(answer, () => answer(null))
}
return true
}
if (event.type === 'agent.terminal.output') {
// Live chunk from a background process → its read-only agent terminal tab.
writeAgentTerminalChunk(payload?.process_id ?? '', payload?.chunk ?? '')
return true
}
if (event.type === 'terminal.close') {
// Agent closed its own read-only tab via the desktop-gated close_terminal tool.
// The process is untouched — this only drops the view.
closeAgentTerminalByProc(payload?.process_id ?? '')
return true
}
if (event.type === 'tour.request') {
// tour tool: run one guided-tour action (highlight/step/discover) via
// driver.js — on the app's own DOM or inside the preview pane's guest
// page — and answer with the outcome. Dynamic import keeps driver.js
// and the preview injection payload off the boot path. Active session
// only: a background turn must never paint overlays on the user's
// screen (desktop AGENTS.md: offer, don't hijack).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const answer = (result: unknown) =>
$gateway.get()?.request('tour.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
if (isActiveEvent) {
void import('@/lib/tour')
.then(({ runTour }) =>
runTour(
{
kind: (payload?.action ?? 'stop') as TourAction['kind'],
selector: payload?.selector,
side: payload?.side as TourStep['side'],
startAt: payload?.step_index,
steps: payload?.steps as TourStep[] | undefined,
text: payload?.text,
title: payload?.title
},
payload?.surface === 'preview' ? 'preview' : 'app'
)
)
.then(answer, error =>
answer({ error: error instanceof Error ? error.message : String(error), success: false })
)
} else {
void answer({
error: 'Tours only run in the session the user is looking at.',
success: false
})
}
}
return true
}
if (event.type === 'pane.reveal') {
// Agent revealed a pane via the desktop-gated focus_pane tool, in
// response to an explicit user request. Active session only — a
// background turn must never move the user's focus (desktop AGENTS.md:
// offer, don't hijack).
if (isActiveEvent) {
revealDesktopPane(payload?.pane ?? '')
}
return true
}
if (event.type === 'layout.apply') {
// Agent applied a layout preset via the desktop-gated apply_layout
// tool. Same contract as pane.reveal: active session only, and the
// preset resolves against the SAME layouts registry the picker reads,
// so core, plugin, and user presets are all addressable.
if (isActiveEvent) {
applyDesktopLayoutPreset(typeof payload?.preset === 'string' ? payload.preset : '')
}
return true
}
if (event.type === 'message.reaction') {
// The agent reacted to a message via the desktop-gated
// react_to_message tool. Already persisted — this only paints it now
// instead of at the next resume. Fresh ChatMessage object per change:
// the runtime repository caches normalized ThreadMessages in a WeakMap
// keyed by ChatMessage identity.
const reactedRowId = payload?.row_id
if (typeof reactedRowId === 'number') {
const nextReactions = Array.isArray(payload?.reactions) ? payload.reactions : []
const reactedRole = payload?.role === 'assistant' ? 'assistant' : 'user'
setMessages(messages => {
// Preferred leg: the message already knows its durable row id
// (rehydrated transcript, or a live row that has round-tripped).
const byRowId = messages.find(message => message.rowId === reactedRowId)
if (byRowId) {
// Overlay survives the end-of-turn resume, which rebuilds from
// in-memory history that doesn't carry this mid-turn DB write.
recordAgentReaction(reactedRowId, nextReactions)
return messages.map(message =>
message.rowId === reactedRowId ? { ...message, reactions: nextReactions } : message
)
}
// Live leg: the targeted message is still optimistic (no rowId —
// it hasn't round-tripped through a resume). The agent's default
// target is the newest message of that role, so stamp the reaction
// AND the now-known row id onto it. Without this the event matches
// nothing and the reaction only appears after a reload.
const lastIndex = messages.findLastIndex(message => message.role === reactedRole && message.rowId === undefined)
if (lastIndex === -1) {
return messages
}
recordAgentReaction(reactedRowId, nextReactions)
return messages.map((message, index) =>
index === lastIndex ? { ...message, rowId: reactedRowId, reactions: nextReactions } : message
)
})
}
return true
}
return false
}
@@ -0,0 +1,264 @@
import { registryBackendScopeKey } from '@hermes/shared'
import { useCallback, useEffect, useRef } from 'react'
import type { GatewayEventPayload } from '@/lib/chat-messages'
import {
approvalReplaySessionId,
resolveGatewayEventSessionId,
UNSCOPED_STREAM_EVENT_TYPES
} from '@/lib/gateway-events'
import { setSessionCompacting } from '@/store/compaction'
import { $gateway, activeGatewayConnectionId } from '@/store/gateway'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { replayPendingApproval } from '@/store/prompts'
import { setSessionProviderWait } from '@/store/provider-wait'
import { setSessionDraftingTool } from '@/store/tool-drafting'
import type { RpcEvent } from '@/types/hermes'
import { handleDesktopBridgeEvent } from './desktop-bridge'
import { handleInputRequestEvent } from './input-requests'
import { handleLifecycleEvent } from './lifecycle'
import { handleMessageStreamEvent } from './message-stream'
import { handleSessionInfoEvent } from './session-info'
import { handleStatusEvent } from './status'
import { handleToolEvent } from './tools'
import type { GatewayEventContext, GatewayEventDeps, GatewayEventHandler } from './types'
export type { GatewayEventDeps } from './types'
/**
* Events that retire a "drafting a tool call" claim.
*
* `tool.generating` opens the claim and nothing closes it — a draft can be
* abandoned without ever reaching `tool.start`, so enumerating the ways one
* *ends* left the label on screen for the rest of the turn. Inverted: the
* claim only covers what the model is emitting right now, and any other output
* from the session proves it moved on. Same rule the TUI applies to its
* transient trail lines (`turnController.pruneTransient`).
*/
const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.delta',
'thinking.delta',
'tool.complete',
'tool.progress',
'tool.start'
])
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'message.interim',
'thinking.delta',
'reasoning.delta',
'reasoning.available',
'moa.reference',
'moa.aggregating',
'moa.progress',
'moa.phase',
'tool.start',
'tool.progress',
'tool.generating',
'tool.complete'
])
const PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES = new Set([
'error',
'message.complete',
'message.delta',
'message.interim',
'message.start',
'reasoning.available',
'reasoning.delta',
'tool.complete',
'tool.generating',
'tool.progress',
'tool.start'
])
// Ordered family handlers; each consumes its own event types and reports
// whether it did, so dispatch stops at the first taker.
const HANDLERS: GatewayEventHandler[] = [
handleLifecycleEvent,
handleSessionInfoEvent,
handleMessageStreamEvent,
handleToolEvent,
handleInputRequestEvent,
handleDesktopBridgeEvent,
handleStatusEvent
]
/** The gateway-event dispatcher, extracted from useMessageStream. */
export function useGatewayEventHandler(deps: GatewayEventDeps) {
const { activeSessionIdRef, compactedTurnRef, refreshHermesConfig, sessionStateByRuntimeIdRef } = deps
const unscopedStreamSessionIdRef = useRef<string | null>(null)
// session.info arrives in bursts (agent build ready + turn end + title /
// MCP / compress edges within the same second). Each used to fire its own
// refreshHermesConfig — two REST calls (config + defaults) per event, per
// turn, including for BACKGROUND sessions whose values the fetch can't even
// apply. Coalesce to one trailing fetch per burst; the caller gates on
// `apply` so background traffic doesn't schedule anything.
const configRefreshTimerRef = useRef<null | number>(null)
const scheduleConfigRefresh = useCallback(() => {
if (configRefreshTimerRef.current !== null) {
return
}
if (typeof window === 'undefined') {
void refreshHermesConfig()
return
}
configRefreshTimerRef.current = window.setTimeout(() => {
configRefreshTimerRef.current = null
void refreshHermesConfig()
}, 300)
}, [refreshHermesConfig])
useEffect(
() => () => {
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(configRefreshTimerRef.current)
configRefreshTimerRef.current = null
}
},
[]
)
return useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
// "From the active profile" must mean "from the active SOURCE": every
// registered connection exposes a 'default' profile, so a bare profile
// comparison attributes gateway B's 'default' events to gateway A's
// 'default'. Compare the composite (connectionId, profile) scope with
// registryBackendScopeKey — untagged primary events keep the legacy
// bare-profile behavior byte-identical.
const fromActiveSource = (): boolean =>
(!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())) &&
registryBackendScopeKey(event.connectionId ?? null, event.profile ?? null) ===
registryBackendScopeKey(activeGatewayConnectionId(), event.profile ?? null)
const occurredAt =
typeof payload?.timestamp === 'number' && Number.isFinite(payload.timestamp)
? payload.timestamp
: Date.now() / 1000
const explicitSid = event.session_id || ''
const route = resolveGatewayEventSessionId({
activeSessionId: activeSessionIdRef.current,
eventType: event.type,
explicitSessionId: explicitSid,
unscopedStreamSessionId: unscopedStreamSessionIdRef.current
})
unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId
if (route.drop) {
return
}
const sessionId = route.sessionId
// Late stragglers: an unscoped stream event attributed via the
// active-session fallback (no pin) to a session that has no live turn
// belongs to a turn that already ended elsewhere. Dropping it keeps the
// previous session's tail events (a delayed `thinking.delta` or
// `status.update`) from landing in a freshly opened chat (#43142 family:
// busy/streaming UI inherited when switching sessions).
if (
sessionId &&
!explicitSid &&
!route.pinned &&
event.type &&
event.type !== 'message.start' &&
UNSCOPED_STREAM_EVENT_TYPES.has(event.type)
) {
const state = sessionStateByRuntimeIdRef.current.get(sessionId)
const hasLiveTurn = Boolean(
state && (state.awaitingResponse || state.busy || state.streamId || state.sawAssistantPayload)
)
if (!hasLiveTurn) {
return
}
}
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
const replaySessionId = approvalReplaySessionId(event.type, activeSessionIdRef.current, sessionId)
if (replaySessionId) {
void replayPendingApproval($gateway.get(), replaySessionId).catch(() => undefined)
}
// Mid-turn compaction does not emit another message.start. The first
// model output or tool event proves summarization has finished and the
// turn has resumed, so retire the phase label without waiting for the
// whole turn to complete.
if (sessionId && COMPACTION_RESUME_EVENT_TYPES.has(event.type) && compactedTurnRef.current.has(sessionId)) {
setSessionCompacting(sessionId, false)
}
if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
setSessionDraftingTool(sessionId, '')
}
if (sessionId && PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
setSessionProviderWait(sessionId, '')
}
const ctx: GatewayEventContext = {
deps,
event,
payload,
sessionId,
explicitSid,
isActiveEvent,
occurredAt,
fromActiveSource,
scheduleConfigRefresh
}
for (const handler of HANDLERS) {
if (handler(ctx)) {
return
}
}
},
// The deps object is rebuilt by the caller each render, but every field it
// carries is stable (refs, useCallback-wrapped fns, queryClient), so
// depending on the individual fields keeps the handler identity stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
deps.appendAssistantDelta,
deps.appendReasoningDelta,
deps.activeSessionIdRef,
deps.activeGatewayProfile,
deps.compactedTurnRef,
deps.completeAssistantMessage,
deps.failAssistantMessage,
deps.finalizeInterimAssistantMessage,
deps.flushQueuedDeltas,
deps.hydrateFromStoredSession,
deps.lastCwdInfoSessionRef,
deps.nativeSubagentSessionsRef,
deps.queryClient,
scheduleConfigRefresh,
deps.scheduleSessionsRefresh,
deps.sessionInterrupted,
deps.sessionStateByRuntimeIdRef,
deps.updateSessionState,
deps.upsertToolCall
]
)
}
@@ -0,0 +1,280 @@
import { translateNow } from '@/i18n'
import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { setMcpSetupRequest } from '@/store/mcp-setup'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { receiveApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import { requestScrollToBottom } from '@/store/thread-scroll'
import type { GatewayEventContext } from './types'
/** The blocking-input family: clarify / MCP setup consent / approval / sudo /
* secret requests. The Python side is blocked on the matching *.respond, so
* each of these must be parked per-session and surfaced. */
export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, occurredAt } = ctx
const { activeSessionIdRef, updateSessionState, upsertToolCall } = deps
if (event.type === 'clarify.request') {
// Surface the clarify tool's overlay. The Python side is blocked on
// `clarify.respond`, so without this handler the agent would hang
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
//
// Store the request for whichever session raised it — even a background
// one. clarify.request is a one-shot event; if we dropped it for an
// unfocused session, that session would block on `clarify.respond`
// indefinitely and re-focusing it could never recover (the event is
// gone). Parking it per-session lets the user answer once they switch
// over; the inline ClarifyTool reads the active session's entry.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
const rawChoices = payload?.choices
const choices = normalizeChoices(rawChoices)
const multiSelect = payload?.multi_select === true
// Batch (multi-question) clarify: `questions` replaces question/choices
// on the wire. `answers` rides along only on reconnect replay, carrying
// the per-question locks the server already accepted.
const questions = normalizeQuestions(payload?.questions)
const lockedAnswers =
typeof payload?.answers === 'object' && payload?.answers !== null
? Object.fromEntries(
Object.entries(payload.answers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: undefined
if (requestId && questions.length > 0) {
setClarifyRequest({
choices: null,
lockedAnswers,
multiSelect: false,
question: '',
questions,
requestId,
sessionId: sessionId ?? null
})
if (sessionId) {
// Same hydration-race guard as the single-question path below: the
// form mounts from the tool row, so upsert a stable one keyed by
// the request id in case tool.start was missed.
upsertToolCall(
sessionId,
{
args: {
questions: questions.map(q => ({
choices: q.choices ?? undefined,
multi_select: q.multiSelect || undefined,
question: q.question
}))
},
name: 'clarify',
tool_id: requestId
},
'running',
event.type,
occurredAt
)
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
if (sessionId === activeSessionIdRef.current) {
requestScrollToBottom()
}
}
dispatchNativeNotification({
body: questions.map(q => q.question).join(' · '),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
} else if (requestId && question) {
if (rawChoices != null && choices.length === 0) {
warnDroppedChoices('gateway', question, rawChoices)
}
setClarifyRequest({
requestId,
question,
choices: choices.length > 0 ? choices : null,
multiSelect,
sessionId: sessionId ?? null
})
if (sessionId) {
// `clarify.request` is the blocking event the Python side waits on,
// while the inline UI normally mounts from the earlier `tool.start`
// row. If that row was missed (stream reconnect / hydration race) the
// sidebar still says "needs input" but there is nowhere to render the
// choices. Upsert a stable pending clarify tool row from the request
// itself so the prompt stays answerable; a real tool.start/complete
// with the same request id merges rather than duplicates.
upsertToolCall(
sessionId,
{
args: { choices, ...(multiSelect ? { multi_select: true } : {}), question },
name: 'clarify',
tool_id: requestId
},
'running',
event.type,
occurredAt
)
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
if (sessionId === activeSessionIdRef.current) {
requestScrollToBottom()
}
}
dispatchNativeNotification({
body: question,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
return true
}
if (event.type === 'mcp.setup.request') {
// setup_mcp tool (desktop GUI): the agent proposed an MCP server and
// the Python side is blocked on mcp.setup.respond. Park the request
// per-session (like clarify) and upsert a stable pending tool row so
// the inline consent card has somewhere to render even when the
// tool.start event was missed (stream reconnect / hydration race).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const server = typeof payload?.server === 'string' ? payload.server : ''
const rawAction = typeof payload?.action === 'string' ? payload.action : 'install'
const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'
const reason = typeof payload?.reason === 'string' ? payload.reason : ''
if (requestId && server) {
setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null })
if (sessionId) {
upsertToolCall(
sessionId,
{ args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId },
'running'
)
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: reason || server,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
return true
}
if (event.type === 'approval.request') {
// Dangerous-command / execute_code approval. The Python side is blocked
// in _await_gateway_decision() until approval.respond lands; without
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
const command = typeof payload?.command === 'string' ? payload.command : ''
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
void receiveApprovalRequest($gateway.get(), {
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
choices: Array.isArray(payload?.choices)
? payload.choices.filter(choice => typeof choice === 'string')
: undefined,
command,
description,
requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined,
sessionId: sessionId ?? null,
smartDenied: payload?.smart_denied === true
}).catch(() => undefined)
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
actions: [
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
],
body: command || description,
kind: 'approval',
sessionId,
title: translateNow('notifications.native.approvalTitle')
})
return true
}
if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
setSudoRequest({ requestId, sessionId: sessionId ?? null })
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
return true
}
if (event.type === 'secret.request') {
// Skill credential capture (tools/skills_tool.py). Blocked on
// secret.respond {request_id, value}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
setSecretRequest({
requestId,
envVar,
prompt: promptText,
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
dispatchNativeNotification({
body: promptText || envVar || translateNow('notifications.native.inputBody'),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
return true
}
return false
}
@@ -0,0 +1,102 @@
import type { HermesSkin } from '@hermes/shared/skin'
import {
notifyCronChanged,
notifyPairingChanged,
notifyPetChanged,
notifyPlatformsChanged,
notifySessionsChanged,
type PetChangeMeta,
setChangeEventsAvailable
} from '@/store/live-sync'
import { dropSessionState, unbindTileRuntime } from '@/store/session-states'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
// module graph into the gateway event hot path.
import { ingestBackendSkin } from '@/themes/backend-sync'
import type { GatewayEventContext } from './types'
/** gateway.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */
export function handleLifecycleEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, fromActiveSource } = ctx
if (event.type === 'gateway.ready') {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
// Backends with the change watcher broadcast pet/cron/sessions change
// events; consumers demote their legacy polls to slow backstops.
setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events))
return true
}
if (event.type === 'skin.changed') {
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
// on another surface). Only the active source+profile's change repaints.
if (fromActiveSource()) {
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
}
return true
}
if (
event.type === 'pet.changed' ||
event.type === 'cron.changed' ||
event.type === 'sessions.changed' ||
event.type === 'platforms.changed' ||
event.type === 'pairing.changed'
) {
// Change-watcher broadcasts (server._broadcast_watched_changes): the
// backend's on-disk signature moved. Route to the live-sync ticks the
// former pollers now subscribe to. Only the active source+profile's
// changes apply — background profile sockets (and other connections'
// gateways) watch their own homes.
if (fromActiveSource()) {
if (event.type === 'pet.changed') {
notifyPetChanged(payload as PetChangeMeta | undefined)
} else if (event.type === 'cron.changed') {
notifyCronChanged()
} else if (event.type === 'platforms.changed') {
notifyPlatformsChanged()
} else if (event.type === 'pairing.changed') {
notifyPairingChanged()
} else {
notifySessionsChanged()
}
}
return true
}
if (event.type === 'session.reclaimed') {
// The backend reclaimed a live session we may still be holding (idle
// TTL, LRU cap, or the WS-orphan reap). Without this the runtime id
// stays cached until something fails against it, which reads as the
// session vanishing rather than being reclaimed. Drop the cached state
// now — the stored row is untouched, so the sidebar keeps the
// conversation and reopening it resumes from the DB.
const reclaimedRuntimeId = String((payload as { session_id?: string } | undefined)?.session_id ?? '')
if (reclaimedRuntimeId) {
dropSessionState(reclaimedRuntimeId)
// A tile bound to the reclaimed runtime would otherwise render an
// empty transcript forever: its view reads $sessionStates[runtime]
// (just dropped) and its resume effect is gated on !runtimeId, so a
// bound tile never re-resumes (#82620). Unbind it so the effect
// refires against the intact stored session — and purge the wiring
// cache's entry, or resumeTile's warm path would hand the dead
// runtime straight back instead of cold-resuming a live one.
unbindTileRuntime(reclaimedRuntimeId)
deps.sessionStateByRuntimeIdRef.current.delete(reclaimedRuntimeId)
}
// The row's ended_at moved, so refresh the lists that render it.
notifySessionsChanged()
return true
}
return false
}
@@ -0,0 +1,392 @@
import type { BillingBlock } from '@hermes/shared'
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
import { translateNow } from '@/i18n'
import { coerceGatewayText, coerceThinkingText } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { notify } from '@/store/notifications'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { clearAllPrompts } from '@/store/prompts'
import { providerWaitText, setSessionProviderWait } from '@/store/provider-wait'
import { setCurrentUsage, setTurnStartedAt } from '@/store/session'
import { pruneFinishedSessionSubagents } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
import type { GatewayEventContext } from './types'
function firstBillingLine(text: string): string {
return (text || '').split('\n')[0]?.trim() ?? ''
}
/**
* A turn failed on a billing wall (out of credits / payment required). The
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
* (Nous → in-app Settings → Billing, other providers → their billing page).
*/
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
if (!raw || typeof raw !== 'object') {
return
}
const block = raw as BillingBlock
if (typeof block.provider !== 'string') {
return
}
setBillingBlock(sessionId, block)
const ctaCopy = {
addCredits: translateNow('billingBlock.addCredits'),
openBilling: translateNow('billingBlock.openBilling')
}
notify({
// Collapse repeat walls from the same provider into one toast.
id: `billing-block:${block.provider}`,
kind: 'warning',
icon: 'credit-card',
title: block.is_nous
? translateNow('billingBlock.titleNous')
: translateNow('billingBlock.titleProvider', block.provider_label),
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
// Sticky: a credit wall blocks every turn until resolved.
durationMs: 0,
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
})
}
/** The message/reasoning/MoA streaming family: message.start → deltas →
* interim → complete, thinking/reasoning deltas, moa.* progress, reaction. */
export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
const {
appendAssistantDelta,
appendReasoningDelta,
compactedTurnRef,
completeAssistantMessage,
finalizeInterimAssistantMessage,
flushQueuedDeltas,
nativeSubagentSessionsRef,
sessionStateByRuntimeIdRef,
updateSessionState
} = deps
if (event.type === 'message.start') {
if (!sessionId) {
return true
}
flushQueuedDeltas(sessionId)
pruneFinishedSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
// A fresh turn on this session optimistically clears its billing wall;
// if credits are still exhausted the next failure re-raises it.
clearBillingBlock(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
}
// Submit→accept latency: seedOptimistic armed the clock at Enter; this
// event is the backend accepting the turn. Debug-only visibility into
// how long the arm actually took (the "no progress box for seconds"
// complaint) — reads the pre-update cache, costs nothing when clean.
const seededAt = sessionStateByRuntimeIdRef.current.get(sessionId)?.turnStartedAt
if (typeof seededAt === 'number') {
console.debug('[turn-accept-latency]', { sessionId, ms: Date.now() - seededAt })
}
updateSessionState(sessionId, state => {
// If the user clicked Stop (cancelRun set interrupted=true), don't
// let a stale message.start from a chained turn (goal follow-up,
// completion drain) or an in-flight LLM response re-arm busy.
// The interrupt is user intent — the backend's cooperative cancel
// may not have propagated yet, so its events are stale. The turn's
// finally block will emit session.info with running=false to clear
// busy for real once the agent loop actually exits.
if (state.interrupted) {
return state
}
return {
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
interimBoundaryPending: false,
// Backend accepted the turn — the no-payload settle gate below may
// now treat a running=false heartbeat as a real turn end.
turnLive: true,
// Keep the submit-time seed (submit.ts seedOptimistic) — resetting
// here would hide the submit→accept round trip from the timer.
// Backend-originated turns (queue drain elsewhere, goal follow-up)
// have no seed and arm here.
turnStartedAt: state.turnStartedAt ?? Date.now()
}
})
if (isActiveEvent) {
// Belt-and-suspenders mirror of the ACTIVE session's per-session
// clock (the load-bearing mirror is the view-sync flush in
// use-session-state-cache). Mirror the seeded value, not Date.now():
// resetting to accept-time here would visibly snap the timer back
// after the submit-time seed above already started it.
setTurnStartedAt(sessionStateByRuntimeIdRef.current.get(sessionId)?.turnStartedAt ?? Date.now())
}
return true
}
if (event.type === 'message.delta') {
if (sessionId) {
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text), occurredAt)
}
return true
}
if (event.type === 'message.interim') {
// The agent emitted interim assistant commentary (text alongside tool
// calls, or the attempted final answer before a verify-on-stop nudge).
// Finalize it as its own sealed bubble so message.complete doesn't wipe
// it — the text was already streamed via message.delta and is visible.
if (sessionId) {
flushQueuedDeltas(sessionId)
const text = coerceGatewayText(payload?.text)
if (text) {
finalizeInterimAssistantMessage(sessionId, text, occurredAt)
}
}
return true
}
if (event.type === 'thinking.delta') {
// Most thinking.delta frames are kawaii spinner rewrites and stay out
// of the transcript. Explained provider waits are different: the core
// emits them after prolonged silence, so name that wait in the existing
// bottom-of-thread status row instead of leaving only an unlabeled timer.
if (sessionId) {
setSessionProviderWait(sessionId, providerWaitText(coerceGatewayText(payload?.text)))
}
return true
}
if (event.type === 'reaction') {
// Core-detected affection (ily / <3 / good bot) on the user's message.
// Play hearts only for the visible session so background turns stay quiet.
if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') {
burstVibeHearts()
}
return true
}
if (event.type === 'reasoning.delta') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), false, occurredAt)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'reasoning.available') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true, occurredAt)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'moa.reference') {
// MoA reference-model output — surface as a labelled thinking chunk
// (tagged with the source model) before the aggregator's response, so
// the mixture-of-agents process is visible. Reuses the reasoning
// disclosure rather than introducing a parallel surface.
if (sessionId) {
const label = coerceGatewayText(payload?.label) || 'reference'
const idx = typeof payload?.index === 'number' ? payload.index : undefined
const cnt = typeof payload?.count === 'number' ? payload.count : undefined
const header = idx && cnt ? `◇ Reference ${idx}/${cnt} — ${label}` : `◇ Reference — ${label}`
const body = coerceThinkingText(payload?.text)
const text = `${header}\n${body}\n\n`
if (idx === undefined || idx <= 1) {
// First reference: clear any stale reasoning left over from
// before this turn's references start, same as before.
appendReasoningDelta(sessionId, text, true, occurredAt)
} else {
// Later references must accumulate, not replace — otherwise
// each new reference wipes out the ones already shown (#64658).
// Queue-then-flush (rather than the streamed/batched queue path)
// applies it immediately, since each reference arrives as one
// complete block rather than incremental tokens. reasoning.delta
// cannot be mid-flight here: MoAChatCompletions.reference_callback
// (agent/moa_loop.py) fires "moa.reference" once per reference's
// already-complete text, with no concurrent token stream for the
// reference-gathering phase, so there is no in-flight delta to
// collide with in the shared queue bucket.
appendReasoningDelta(sessionId, text, false, occurredAt)
flushQueuedDeltas(sessionId)
}
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'moa.aggregating') {
// Status transition only; the aggregator's reply arrives via the normal
// message stream. No reasoning/transcript mutation here.
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'moa.progress') {
// Live reference fan-out progress ("refs k/n") — surfaced in the same
// reasoning disclosure the references land in. These lines arrive
// BEFORE any moa.reference event (references are only emitted once the
// whole fan-out completes), and the first moa.reference replaces the
// block, so the progress trail is self-cleaning.
if (sessionId && typeof payload?.refs_done === 'number' && typeof payload?.refs_total === 'number') {
const label = coerceGatewayText(payload?.label)
const line = label
? `◇ MoA refs ${payload.refs_done}/${payload.refs_total} — ${label}\n`
: `◇ MoA refs ${payload.refs_done}/${payload.refs_total}\n`
appendReasoningDelta(sessionId, line, payload.refs_done <= 1, occurredAt)
flushQueuedDeltas(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'moa.phase') {
// Phase transition — currently only phase="aggregator" (fan-out done,
// aggregator acting). Append a one-line marker; the first
// moa.reference that follows replaces the whole block.
if (sessionId && payload?.phase === 'aggregator') {
appendReasoningDelta(sessionId, '◇ MoA aggregating…\n', false, occurredAt)
flushQueuedDeltas(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
return true
}
if (event.type === 'message.complete') {
if (!sessionId) {
return true
}
// Turn ended — drop any blocking prompt still open for THIS session
// (e.g. interrupted, or the approval already resolved). Scoped to the
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
// Turn ended without a final `todo` update — drop a still-unfinished
// list so "Tasks N/M" doesn't stay pinned above the composer with the
// last item stuck pending/in_progress. Finished lists keep their linger.
clearActiveSessionTodos(sessionId)
setSessionCompacting(sessionId, false)
flushQueuedDeltas(sessionId)
// Keyed by session so only one window beeps when several are open.
playCompletionSound(sessionId)
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
// Terminal error frames (status "error") carry the failure in
// structured fields: `error` is the message, and `partial` marks
// `text` as streamed output to keep rather than the error string.
const failure =
payload?.status === 'error'
? {
error: coerceGatewayText(payload.error).trim() || finalText || 'Hermes reported an error',
partial: Boolean(payload.partial)
}
: undefined
completeAssistantMessage(sessionId, finalText, payload?.response_previewed, failure, occurredAt)
// Structured billing wall forwarded by the gateway (out of credits /
// payment required) — cache it + raise a billing-specific toast.
if (payload?.billing) {
surfaceBillingBlock(sessionId, payload.billing)
}
if (isActiveEvent) {
setTurnStartedAt(null)
// Pet beat: a finished turn always celebrates — go straight to the
// jump, never linger on the run/reason pose. One atom update (clears
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
// frame leaks to the sprite — including the popped-out overlay, which
// mirrors each activity change. The jump runs ~2 loops, then settles.
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
// Light up the pet's mail icon if the user wasn't looking when the turn
// finished — a glanceable "new message" hint on the popped-out overlay.
// Cleared when they open the app via the mail icon or refocus the window.
if (typeof document !== 'undefined' && !document.hasFocus()) {
markPetUnread()
}
}
if (payload?.usage) {
// Per-session twin FIRST (the statusbar reads it for focused tiles);
// the primary-only global mirrors the ACTIVE session — ungated it
// let a background tile's turn overwrite the primary's count.
updateSessionState(sessionId, state => ({
...state,
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
}))
if (isActiveEvent) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
}
return true
}
return false
}
@@ -0,0 +1,385 @@
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { followActiveSessionCwd } from '@/store/projects'
import {
$currentCwd,
$currentModel,
$currentProvider,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwdTransient,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setSessions,
setTerminalBackend,
setWorkspaceCwdOwner,
setYoloActive
} from '@/store/session'
import { reportInstallMethodWarning } from '@/store/updates'
import { finalizeInterruptedMessages } from '../../use-prompt-actions/rewind'
import { hasSessionInfoStatePatch, PRE_TURN_LIVE_SETTLE_GRACE_MS, sessionInfoStatePatch } from '../utils'
import type { GatewayEventContext } from './types'
/**
* Whether a `session.info` payload's `stored_session_id` may be treated as the
* selected conversation's, so its cwd can be claimed for it (#71254).
*
* Absent is not the same as different: the backend omits the id on a
* not-yet-built (`lazy`) session, and refusing there would leave the workspace
* marked un-owned for the rest of the conversation. Matching goes through the
* lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root
* a pinned-row selection may hold still read as one conversation.
*/
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean {
const infoStoredSessionId = storedSessionId?.trim() || null
const selected = $selectedStoredSessionId.get() ?? null
if (!infoStoredSessionId) {
return true
}
// A named session cannot describe a fresh draft. Treating a null selection as
// a wildcard let a background tile's `session.info` rehome the draft to the
// tile's workspace.
if (!selected) {
return false
}
if (infoStoredSessionId === selected) {
return true
}
// Either id may be the live tip or the lineage root, so ask whether ONE row
// answers to both rather than assuming which side rotated.
return $sessions
.get()
.some(session => sessionMatchesStoredId(session, infoStoredSessionId) && sessionMatchesStoredId(session, selected))
}
/** session.info / session.usage / session.title. */
export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, explicitSid, isActiveEvent, occurredAt, fromActiveSource } = ctx
const {
activeGatewayProfile,
activeSessionIdRef,
hydrateFromStoredSession,
lastCwdInfoSessionRef,
queryClient,
scheduleSessionsRefresh,
sessionStateByRuntimeIdRef,
updateSessionState
} = deps
if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const statePatch = sessionInfoStatePatch(payload)
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
// The backend stamps model/provider (as strings) on EVERY session.info,
// so the presence flags above are true on every heartbeat/turn edge —
// fine for the cheap atom writes below (nanostores skips identical
// values), but they also drove queryClient.invalidateQueries, refetching
// the model-options provider catalog once or twice per turn for a model
// that never changed. Only a genuine VALUE change (vs the session's own
// cached runtime state, captured before the state patch below applies;
// composer atoms as the fallback for an uncached session) invalidates.
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
const providerValueChanged =
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
// Config is profile-scoped, but session.info also arrives for background
// sessions. Only an active-session event from the currently active
// gateway may reconcile the foreground cache. Requiring the renderer's
// source tag prevents an event queued before a profile swap from being
// attributed to the newly active profile.
if (isActiveEvent && typeof payload?.approval_mode === 'string' && event.profile && fromActiveSource()) {
reconcileApprovalModeForProfile(event.profile, payload.approval_mode)
}
if (apply) {
// Do not call setCurrentModel / setCurrentProvider here. Composer
// model/provider is sticky UI state (localStorage + manual picks).
// Periodic session.info heartbeats often carry the profile default
// (or a stale session model) and would silently revert the dropdown.
// Active-session model/provider still flows through the session state
// cache via updateSessionState → syncRuntimeMetadataToView below.
if (typeof payload?.cwd === 'string' && sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
// The active session's agent can relocate itself (new repo/worktree
// via the terminal). When the SAME active session's cwd actually
// moves, follow it — refresh the project tree + scope so the sidebar
// tracks the live thread. A fresh selection (different session id)
// is a switch, not a move, so it refreshes data without yanking scope.
const cwdMoved = payload.cwd !== $currentCwd.get()
const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
lastCwdInfoSessionRef.current = sessionId
setCurrentCwdTransient(payload.cwd)
// The backend just confirmed the selected conversation's real
// workspace, so it owns the path we wrote. Without the claim the
// marker keeps naming whoever held it before — including the
// released state a detached resume leaves behind — and the primary
// workspace-derived surfaces stay hidden against a folder the
// backend has confirmed (#71254).
setWorkspaceCwdOwner($selectedStoredSessionId.get())
if (cwdMoved && sameSession) {
void followActiveSessionCwd(payload.cwd)
}
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
}
if (typeof payload?.terminal_backend === 'string') {
setTerminalBackend(payload.terminal_backend)
}
if (typeof payload?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(payload.personality))
}
if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
}
if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
}
if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
}
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
}
}
if (sessionId && hasStatePatch) {
updateSessionState(
sessionId,
state => ({
...state,
...statePatch,
branch: statePatch.branch ?? state.branch,
cwd: statePatch.cwd ?? state.cwd
}),
payload?.stored_session_id || undefined
)
}
// The running→busy transition must reach EVERY session, not just the
// active one. The `apply` gate above correctly scopes view-only side
// effects (setCurrentCwd, etc.) to the focused chat,
// but the per-session busy state is what drives the sidebar working
// indicator — a background session's turn start/finish must update
// its dot without the user opening it. updateSessionState only
// mutates the per-runtime cache entry, and syncSessionStateToView
// guards the view publish to the active session, so this is safe.
if (runningChanged && sessionId) {
// Set when THIS event released a turn that ended without ever
// producing an assistant payload, so the catch-up side effects below
// run on that edge only. The updater is invoked exactly once,
// synchronously, by updateSessionState.
let recoveredWithoutPayload = false
const nextState = updateSessionState(
sessionId,
state => {
const busy = Boolean(payload!.running)
if (state.busy === busy && (busy || !state.awaitingResponse)) {
return state
}
if (busy) {
// Don't re-arm busy from a stale session.info if the user
// just clicked Stop (interrupted=true). The backend's
// cooperative interrupt may not have propagated yet, so
// running is still true in the heartbeat. The turn's
// finally block will emit running=false to clear busy.
if (state.interrupted) {
return state
}
// Prefer the gateway-reported turn_started_at so the timer
// survives session switches and session.info heartbeats.
const gatewayTurnStartedAt =
typeof payload!.turn_started_at === 'number' && payload!.turn_started_at > 0
? payload!.turn_started_at * 1000
: null
return {
...state,
busy,
// running=true from the backend is turn-live proof, same as
// message.start (e.g. resuming an already-running session
// that never replays its start event).
turnLive: true,
turnStartedAt: state.turnStartedAt ?? gatewayTurnStartedAt ?? Date.now()
}
}
// The turn has not started backend-side yet. submit arms
// busy/awaitingResponse optimistically, so a running=false
// heartbeat that lands in the gap before the turn spins up is a
// pre-start report, not a finished turn — settling on it would
// drop the spinner and re-open the send guard mid-flight.
// turnLive is stamped only once the backend reports the turn
// live (message.start, the running=true edge, or a resumed
// in-flight turn) and is cleared by every settle, so false
// here is exactly "no turn has been reported running yet".
// (turnStartedAt can't discriminate — it is optimistically
// seeded at submit so the visible timer starts at Enter.)
//
// BOUNDED (#86795): an armed turn that never goes live — a
// restore/edit whose rewind was refused after the optimistic
// arm, a submit response lost to a gateway bounce, a terminal
// error event that never arrived — would otherwise hold this
// gate forever. busy then latches until app restart:
// isTargetSessionBusy refuses every send, the composer queues
// each message, and the queue drain (gated on busy→false) never
// fires. turnStartedAt is seeded at the optimistic arm, so its
// age bounds the hold; past the grace window (or with no clock
// at all) the gateway's running=false is authoritative and the
// settle below releases the session.
const armedAt = state.turnStartedAt
const withinPreStartGrace =
typeof armedAt === 'number' && Date.now() - armedAt < PRE_TURN_LIVE_SETTLE_GRACE_MS
if (state.awaitingResponse && !state.sawAssistantPayload && !state.turnLive && withinPreStartGrace) {
return state
}
// Past that gate the turn DID start and the backend now reports it
// finished. When no assistant payload ever arrived (gateway crash
// mid-stream, provider error before the first delta, agent-build
// failure) message.complete never fires, so this is the only event
// that can release the session. Bailing here instead left
// awaitingResponse/busy latched until app restart (#46517): the
// per-session busy flag is authoritative for isTargetSessionBusy,
// so submitPrompt and the slash dispatcher silently returned false
// and the session accepted no further input.
recoveredWithoutPayload = state.awaitingResponse && !state.sawAssistantPayload
return {
...state,
awaitingResponse: false,
busy,
// The turn is over but its streaming bubble may still say
// pending — running=false from the agent loop's finally block
// is the ONLY settle signal when message.complete never
// arrives (turn crash, reconnect gap). Left pending, that
// bubble shows a thinking indicator forever, stranded
// mid-transcript once the next user message lands after it.
// finalizeInterruptedMessages un-pends kept text and drops
// empty placeholders; on the normal path message.complete
// already settled everything and this is a no-op.
messages: finalizeInterruptedMessages(state.messages, state.streamId, occurredAt),
pendingBranchGroup: null,
streamId: null,
turnStartedAt: null,
turnLive: false
}
},
payload?.stored_session_id || undefined
)
if (recoveredWithoutPayload) {
// Stays unscoped, like the settle above: a background session's
// sidebar row has to drop its working dot without the user opening
// it. This fires on the recovery edge only — once awaitingResponse
// is false the `state.busy === busy` guard above short-circuits
// every later heartbeat — so it costs one coalesced refresh per
// broken turn, not one per tick.
scheduleSessionsRefresh()
// The transcript catch-up IS scoped. The stream died, but the turn
// itself may have completed and been persisted, so refetch stored
// history for the session actually on screen; a background session
// reads its history when the user opens it, and hydrating every one
// of them here would fan a REST call out per idle session.
if (isActiveEvent) {
void hydrateFromStoredSession(3, nextState.storedSessionId, sessionId)
}
}
}
if (payload?.usage && (!explicitSid || isActiveEvent)) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
// Config refetch is only meaningful for the foreground context —
// everything refreshHermesConfig applies is either active-session
// guarded or a composer/global pref. Background sessions' heartbeats
// used to trigger it too (two REST calls each, every turn).
ctx.scheduleConfigRefresh()
}
if (modelValueChanged || providerValueChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? modelOptionsQueryKey(activeGatewayProfile, sessionId) : ['model-options']
})
}
return true
}
if (event.type === 'session.usage') {
// Live usage tick emitted while a turn is mid-flight (see tui_gateway
// _start_usage_ticker) so the status-bar context window tracks growth
// during the turn instead of only jumping at message.complete.
if (payload?.usage && sessionId) {
// Per-session twin first: a focused secondary tile reads this cache,
// while the primary-only global mirrors the active session.
updateSessionState(sessionId, state => ({
...state,
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
}))
if (isActiveEvent) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
}
return true
}
if (event.type === 'session.title') {
// Live auto-title push (titler runs async, after the turn's refresh).
const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
if (storedId && nextTitle) {
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
}
return true
}
return false
}
@@ -0,0 +1,177 @@
import { translateNow } from '@/i18n'
import { textPart } from '@/lib/chat-messages'
import { coerceGatewayText } from '@/lib/chat-runtime'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { clearClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { applyGoalStatusText } from '@/store/goals'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { isDiskFullErrorMessage, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, setPetActivity } from '@/store/pet'
import { clearAllPrompts } from '@/store/prompts'
import { setTurnStartedAt } from '@/store/session'
import { clearActiveSessionTodos } from '@/store/todos'
import type { GatewayEventContext } from './types'
/** status.update / review.summary / notification.show / notification.clear /
* error — the status-and-notice tail of the dispatcher. */
export function handleStatusEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
const { compactedTurnRef, failAssistantMessage, flushQueuedDeltas, queryClient, updateSessionState } = deps
if (event.type === 'status.update') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)
compactedTurnRef.current.add(sessionId)
} else if (sessionId && payload?.kind === 'compacted') {
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
} else if (sessionId && payload?.kind === 'process') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
} else if (sessionId && payload?.kind === 'goal') {
applyGoalStatusText(sessionId, coerceGatewayText(payload?.text))
}
return true
}
if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
//
// Typed here with the `review:` marker (same convention as `steer:` /
// `slash:`) so SystemMessage can paint it as the memory-write row it
// is instead of sniffing the backend's prose. The leading 💾 goes with
// it — the row draws its own glyph.
const text = coerceGatewayText(payload?.text)
.trim()
.replace(/^[^\p{L}\p{N}]+/u, '')
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(`review:${text}`, occurredAt)],
timestamp: occurredAt
}
]
}))
}
return true
}
if (event.type === 'notification.show') {
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
// status bar; the desktop renders them as toasts. The notice key doubles
// as the toast id, so the escalating 50→75→90 credits line replaces in
// place instead of stacking. Account-wide signal — shown regardless of
// which session is focused.
const notice = event.payload as AgentNoticePayload | undefined
showAgentNotice(notice)
// The urgent pair (access paused / restored) also breaks through as a
// native OS notification when Hermes is backgrounded; dispatch is gated
// by the user's notification prefs + backgrounded check.
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
if (native) {
dispatchNativeNotification(native)
}
// A credits crossing moves the account balance. Settings → Billing polls
// `billing.state` every 30s; nudge it so the page reflects the crossing
// immediately instead of up to 30s late.
if (notice?.key?.startsWith('credits.')) {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
return true
}
if (event.type === 'notification.clear') {
// Key-matched dismissal (e.g. credits restored clears the depleted
// notice). notify() keys the toast by the notice key, so this maps
// straight to dismissNotification(key).
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
return true
}
if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
// A turn that errors out has also ended — drop any open blocking prompt
// for this session so an approval/sudo/secret overlay can't linger past
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
clearActiveSessionTodos(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity({ error: true })
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',
sessionId,
title: translateNow('notifications.native.turnErrorTitle')
})
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isDiskFullErrorMessage(errorMessage)) {
notifyError(new Error(errorMessage), translateNow('notifications.errors.diskFull'))
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
notify({
id: `gateway-error:${errorMessage}`,
kind: 'error',
title: 'Hermes error',
message: errorMessage
})
}
if (sessionId) {
flushQueuedDeltas(sessionId)
failAssistantMessage(sessionId, errorMessage, occurredAt)
}
if (isActiveEvent) {
setTurnStartedAt(null)
}
return true
}
return false
}
@@ -0,0 +1,141 @@
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { flashPetActivity, setPetActivity } from '@/store/pet'
import { pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { reportMcpToolResult } from '@/store/suggestion-providers/repair'
import { invalidateSkillSuggestionIndex } from '@/store/suggestion-providers/skill'
import { recordToolDiff } from '@/store/tool-diffs'
import { setSessionDraftingTool } from '@/store/tool-drafting'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
import { SUBAGENT_EVENT_TYPES, toTodoPayload } from '../utils'
import type { GatewayEventContext } from './types'
/** tool.generating / tool.start / tool.progress / tool.complete / subagent.*. */
export function handleToolEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
const { flushQueuedDeltas, nativeSubagentSessionsRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps
if (event.type === 'tool.generating') {
// Announced while the model is still emitting the call's JSON, so it
// carries a name and nothing else — no id, no args. Materializing a row
// from it strands an argless placeholder whenever the bubble is sealed
// before the real `tool.start` arrives, because the two can no longer be
// reconciled across the boundary. It's a status, so say it as one.
// A stopped turn can still emit a frame or two before the backend
// notices, and naming a tool we will never run leaves the label up
// until something else retires it. `mutateStream` drops late tool rows
// on the same condition; the status line has to agree with it.
if (!sessionId || sessionInterrupted(sessionId)) {
return true
}
setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '')
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: true })
}
return true
}
if (event.type === 'tool.start' || event.type === 'tool.progress') {
if (!sessionId) {
return true
}
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type, occurredAt)
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: true })
}
return true
}
if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type, occurredAt)
if (isActiveEvent) {
setPetActivity({ toolRunning: false })
// A tool can fail without ending the turn when the agent recovers
// and continues. Surface that failure as a short pet beat too;
// otherwise only turn-level errors ever reach the failed state.
if (payload?.error) {
flashPetActivity({ error: true })
}
}
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
// terminal/process tool calls are the only things that spawn or reap
// background processes — sync the composer status stack right after.
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
void refreshBackgroundProcesses(sessionId)
}
}
// The agent just created/deleted/renamed a skill, which adds or removes
// its `/name` command. Drop the composer's cached `/` list so the new
// skill is offerable now rather than after the hour-long TTL — and the
// skill-suggestion provider's index with it.
if (payload?.name === 'skill_manage') {
invalidateSlashCompletions()
invalidateSkillSuggestionIndex()
}
// MCP tool outcomes feed the connection-repair suggestion provider:
// an auth/connection-shaped failure offers a reconnect pill; a later
// success against the same server withdraws it.
if (sessionId && typeof payload?.name === 'string' && payload.name.startsWith('mcp__')) {
reportMcpToolResult(
sessionId,
payload.name,
Boolean(payload.error),
[payload.error, payload.result].filter(part => typeof part === 'string').join(' ')
)
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
// A file-mutating tool just finished — nudge the git-mirroring surfaces
// (coding rail, review pane, file tree) to refresh. Event-driven, not
// polled: fires exactly when the agent touches the tree.
if (payload && toolMayMutateFiles(payload)) {
notifyWorkspaceChanged(toolChangedPath(payload))
}
return true
}
if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
pruneDelegateFallbackSubagents(sessionId)
}
nativeSubagentSessionsRef.current.add(sessionId)
upsertSubagent(
sessionId,
payload as Record<string, unknown>,
event.type === 'subagent.spawn_requested' || event.type === 'subagent.start',
event.type
)
}
return true
}
return false
}
@@ -0,0 +1,72 @@
import type { QueryClient } from '@tanstack/react-query'
import type { MutableRefObject } from 'react'
import type { GatewayEventPayload } from '@/lib/chat-messages'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../../../types'
export interface GatewayEventDeps {
activeGatewayProfile: string
activeSessionIdRef: MutableRefObject<string | null>
compactedTurnRef: MutableRefObject<Set<string>>
lastCwdInfoSessionRef: MutableRefObject<string | null>
nativeSubagentSessionsRef: MutableRefObject<Set<string>>
appendAssistantDelta: (sessionId: string, delta: string, occurredAt?: number) => void
appendReasoningDelta: (sessionId: string, delta: string, replace?: boolean, occurredAt?: number) => void
completeAssistantMessage: (
sessionId: string,
text: string,
responsePreviewed?: boolean,
failure?: { error: string; partial: boolean },
occurredAt?: number
) => void
failAssistantMessage: (sessionId: string, errorMessage: string, occurredAt?: number) => void
flushQueuedDeltas: (sessionId?: string) => void
finalizeInterimAssistantMessage: (sessionId: string, text: string, occurredAt?: number) => void
hydrateFromStoredSession: (
attempts?: number,
storedSessionId?: string | null,
runtimeSessionId?: string | null
) => Promise<void>
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
scheduleSessionsRefresh: () => void
sessionInterrupted: (sessionId: string) => boolean
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
upsertToolCall: (
sessionId: string,
payload: GatewayEventPayload | undefined,
phase: 'running' | 'complete',
sourceEventType?: string,
occurredAt?: number
) => void
}
/** Everything a per-family handler needs about the event being dispatched —
* the routing preamble in index.ts computes it once per event. */
export interface GatewayEventContext {
deps: GatewayEventDeps
event: RpcEvent
payload: GatewayEventPayload | undefined
/** Routed session id (explicit, pinned unscoped stream, or active fallback). */
sessionId: null | string
/** The raw `session_id` on the event ('' when unscoped). */
explicitSid: string
/** The routed session is the one on screen. */
isActiveEvent: boolean
/** Event timestamp in epoch seconds (payload timestamp or receipt time). */
occurredAt: number
/** The event came from the active (connection, profile) source. */
fromActiveSource: () => boolean
/** Coalesced trailing refreshHermesConfig (one per session.info burst). */
scheduleConfigRefresh: () => void
}
/** A family handler consumes matching event types and reports whether it did. */
export type GatewayEventHandler = (ctx: GatewayEventContext) => boolean
@@ -1,72 +1,41 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { chatMessageText } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearSessionTodos } from '@/store/todos'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let sessionStates: Map<string, ClientSessionState>
let stream: MessageStreamHarness
let mockCompleteSound: ReturnType<typeof vi.fn>
let mockHaptic: ReturnType<typeof vi.fn>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
sessionStates.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
function mountStream() {
stream = renderMessageStream(SID)
}
async function mountStream() {
sessionStates = new Map()
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const start = () => act(() => stream.handleEvent({ payload: {}, session_id: SID, type: 'message.start' }))
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
const delta = (text: string) =>
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.delta' }))
const interim = (text: string) =>
act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
act(() => stream.handleEvent({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
const complete = (text: string) =>
act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.complete' }))
const completePreviewed = (text: string) =>
act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))
act(() =>
stream.handleEvent({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' })
)
function getState(): ClientSessionState {
return sessionStates.get(SID) ?? createClientSessionState()
return stream.state()
}
function assistantText(): string {
@@ -87,7 +56,6 @@ function assistantMessages(): string[] {
describe('useMessageStream interim text sealing', () => {
beforeEach(() => {
handleEvent = null
clearSessionTodos(SID)
})
@@ -98,7 +66,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('preserves interim text that the final response does not include', async () => {
await mountStream()
mountStream()
await start()
await delta('awaaaaa clean!! tsc zero errors')
@@ -112,7 +80,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => {
await mountStream()
mountStream()
await start()
await delta('Let me check the files.')
@@ -130,7 +98,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('clears the interim mark when a previewed final settles onto the interim bubble', async () => {
await mountStream()
mountStream()
await start()
await interim('same reply')
@@ -142,7 +110,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('dedupes interim text when the final response includes it', async () => {
await mountStream()
mountStream()
await start()
await delta('Let me check the files.')
@@ -156,7 +124,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('clears interimBoundaryPending at turn end so the next turn starts clean', async () => {
await mountStream()
mountStream()
await start()
await delta('interim text')
@@ -175,7 +143,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('finalizes an interim segment without settling the turn', async () => {
await mountStream()
mountStream()
await start()
await delta('streaming text')
@@ -187,7 +155,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('settles an identical final onto a non-previewed interim (tool-call turn) instead of duplicating (#63679)', async () => {
await mountStream()
mountStream()
await start()
// A plain tool-call turn: the streamed text is sealed as an interim at the
@@ -203,7 +171,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('settles a prefix-extended final onto a non-previewed interim (streamed + trailing delta)', async () => {
await mountStream()
mountStream()
await start()
// The stream dropped/settled early at the tool boundary; the final adds a
@@ -218,7 +186,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('settles final onto interim even after message.start reset the boundary flag (#74560)', async () => {
await mountStream()
mountStream()
await start()
await delta('partial')
await interim('partial')
@@ -235,12 +203,12 @@ describe('useMessageStream interim text sealing', () => {
})
it('appends a distinct previewed final after a message.start reset instead of overwriting the interim', async () => {
await mountStream()
mountStream()
await start()
await interim('old interim text')
// A genuinely new turn begins — message.start resets interimBoundaryPending.
// Production ordering: mid-turn compaction-resume events do NOT include
// message.start (COMPACTION_RESUME_EVENT_TYPES in gateway-event.ts), and
// message.start (COMPACTION_RESUME_EVENT_TYPES in gateway-event/index.ts), and
// the TUI gateway emits message.complete BEFORE goal-followup starts
// (tui_gateway/server.py), so a previewed final arriving after the reset
// is a DISTINCT reply, not a rewrite of the interim. It must append its
@@ -255,7 +223,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('appends a genuinely different final as its own bubble (two real assistant segments)', async () => {
await mountStream()
mountStream()
await start()
// The interim is one segment (pre-tool commentary); the final is different
@@ -271,7 +239,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('settles an identical final completion onto the interim when response_previewed', async () => {
await mountStream()
mountStream()
await start()
await interim('same reply')
@@ -285,7 +253,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('settles a prefix-matched final onto the interim when response_previewed', async () => {
await mountStream()
mountStream()
await start()
// Interim text is a PREFIX of the final — the model streamed part of
@@ -302,7 +270,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('dedupes partial-stream-then-nudge: streamed prefix + interim + previewed final settles to one bubble', async () => {
await mountStream()
mountStream()
await start()
// The model streamed part of its answer via message.delta, then the
@@ -319,16 +287,16 @@ describe('useMessageStream interim text sealing', () => {
})
it('ignores malformed message.interim payload', async () => {
await mountStream()
mountStream()
await start()
// No payload at all
await act(() => handleEvent!({ type: 'message.interim' } as RpcEvent))
await act(() => stream.handleEvent({ type: 'message.interim' } as RpcEvent))
// Empty text
await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
await act(() => stream.handleEvent({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
// Undefined text
await act(() =>
handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
stream.handleEvent({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
)
// Turn continues without finalizing or throwing
@@ -337,7 +305,7 @@ describe('useMessageStream interim text sealing', () => {
})
it('clears interimBoundaryPending on message.start', async () => {
await mountStream()
mountStream()
await start()
await delta('interim text')
@@ -1,88 +1,41 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let latestState: ClientSessionState | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
latestState = next
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
act(() => stream.handleEvent({ payload, session_id: SID, type }))
}
function reasoningText(): string {
const message = latestState?.messages.at(-1)
const part = message?.parts.find(p => p.type === 'reasoning')
return part?.type === 'reasoning' ? part.text : ''
}
beforeEach(() => {
handleEvent = null
latestState = null
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('useMessageStream moa.progress / moa.phase surfacing', () => {
it('shows refs k/n lines in the reasoning block as references complete', async () => {
await mountStream()
it('shows refs k/n lines in the reasoning block as references complete', () => {
mountStream()
emit('message.start')
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 3 })
emit('moa.progress', { label: 'model-b', refs_done: 2, refs_total: 3 })
const text = reasoningText()
const text = stream.reasoningText()
expect(text).toContain('MoA refs 1/3 — model-a')
expect(text).toContain('MoA refs 2/3 — model-b')
})
it('restarts the progress block on the first ref of a new fan-out', async () => {
await mountStream()
it('restarts the progress block on the first ref of a new fan-out', () => {
mountStream()
emit('message.start')
emit('moa.progress', { label: 'stale', refs_done: 1, refs_total: 2 })
@@ -90,32 +43,32 @@ describe('useMessageStream moa.progress / moa.phase surfacing', () => {
// A later turn's fan-out starts over at 1/N — old lines must not linger.
emit('moa.progress', { label: 'fresh', refs_done: 1, refs_total: 2 })
const text = reasoningText()
const text = stream.reasoningText()
expect(text).toContain('MoA refs 1/2 — fresh')
expect(text).not.toContain('stale')
})
it('appends the aggregating marker on moa.phase and ignores unknown phases', async () => {
await mountStream()
it('appends the aggregating marker on moa.phase and ignores unknown phases', () => {
mountStream()
emit('message.start')
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 1 })
emit('moa.phase', { phase: 'reference', refs_done: 1, refs_total: 1 })
expect(reasoningText()).not.toContain('aggregating')
expect(stream.reasoningText()).not.toContain('aggregating')
emit('moa.phase', { aggregator: 'agg-model', phase: 'aggregator', refs_done: 1, refs_total: 1 })
expect(reasoningText()).toContain('MoA aggregating…')
expect(stream.reasoningText()).toContain('MoA aggregating…')
})
it('a following moa.reference replaces the progress trail (self-cleaning)', async () => {
await mountStream()
it('a following moa.reference replaces the progress trail (self-cleaning)', () => {
mountStream()
emit('message.start')
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 1 })
emit('moa.phase', { phase: 'aggregator', refs_done: 1, refs_total: 1 })
emit('moa.reference', { count: 1, index: 1, label: 'model-a', text: 'advice-a' })
const text = reasoningText()
const text = stream.reasoningText()
expect(text).toContain('Reference 1/1 — model-a')
expect(text).not.toContain('MoA refs')
expect(text).not.toContain('aggregating')
@@ -1,81 +1,34 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let latestState: ClientSessionState | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
latestState = next
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
}
function reasoningText(): string {
const message = latestState?.messages.at(-1)
const part = message?.parts.find(p => p.type === 'reasoning')
return part?.type === 'reasoning' ? part.text : ''
act(() => stream.handleEvent({ payload, session_id: SID, type }))
}
describe('useMessageStream moa.reference accumulation (#64658)', () => {
beforeEach(() => {
handleEvent = null
latestState = null
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('keeps every reference model labelled block instead of only the latest one', async () => {
await mountStream()
it('keeps every reference model labelled block instead of only the latest one', () => {
mountStream()
emit('moa.reference', { count: 2, index: 1, label: 'model-a', text: 'advice-a' })
emit('moa.reference', { count: 2, index: 2, label: 'model-b', text: 'advice-b' })
const text = reasoningText()
const text = stream.reasoningText()
expect(text).toContain('model-a')
expect(text).toContain('advice-a')
@@ -83,25 +36,25 @@ describe('useMessageStream moa.reference accumulation (#64658)', () => {
expect(text).toContain('advice-b')
})
it('handles a single-reference MoA turn (count=1) without regression', async () => {
await mountStream()
it('handles a single-reference MoA turn (count=1) without regression', () => {
mountStream()
emit('moa.reference', { count: 1, index: 1, label: 'model-a', text: 'only-advice' })
const text = reasoningText()
const text = stream.reasoningText()
expect(text).toContain('model-a')
expect(text).toContain('only-advice')
})
it('accumulates three or more references in order', async () => {
await mountStream()
it('accumulates three or more references in order', () => {
mountStream()
emit('moa.reference', { count: 3, index: 1, label: 'model-a', text: 'advice-a' })
emit('moa.reference', { count: 3, index: 2, label: 'model-b', text: 'advice-b' })
emit('moa.reference', { count: 3, index: 3, label: 'model-c', text: 'advice-c' })
const text = reasoningText()
const text = stream.reasoningText()
const orderOk =
text.indexOf('advice-a') < text.indexOf('advice-b') && text.indexOf('advice-b') < text.indexOf('advice-c')
@@ -1,60 +1,26 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $petActivity, $petState, setPetActivity } from '@/store/pet'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
const OTHER_SID = 'session-2'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID)
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
act(() => handleEvent!({ payload, session_id: sessionId, type }))
act(() => stream.handleEvent({ payload, session_id: sessionId, type }))
}
describe('pet tool-failure reaction', () => {
beforeEach(() => {
handleEvent = null
setPetActivity({
busy: false,
awaitingInput: false,
@@ -72,8 +38,8 @@ describe('pet tool-failure reaction', () => {
vi.restoreAllMocks()
})
it('briefly shows failed when the active session has an isolated tool error', async () => {
await mountStream()
it('briefly shows failed when the active session has an isolated tool error', () => {
mountStream()
emit('tool.start', { name: 'terminal', tool_id: 'tool-1' })
emit('tool.complete', { name: 'terminal', tool_id: 'tool-1', error: 'exit code 1' })
@@ -82,8 +48,8 @@ describe('pet tool-failure reaction', () => {
expect($petState.get()).toBe('failed')
})
it('does not show failed for a successful tool or a background-session failure', async () => {
await mountStream()
it('does not show failed for a successful tool or a background-session failure', () => {
mountStream()
emit('tool.complete', { name: 'terminal', tool_id: 'tool-1' })
expect($petActivity.get().error).toBe(false)
@@ -1,57 +1,23 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $providerWaitSessions } from '@/store/provider-wait'
import { clearAllSessionStates, dropSessionState } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
let stream: MessageStreamHarness
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
act(() => stream.handleEvent({ payload, session_id: SID, type }))
}
describe('provider wait visibility', () => {
beforeEach(async () => {
handleEvent = null
$providerWaitSessions.set({})
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
stream = renderMessageStream(SID)
})
afterEach(() => {
@@ -1,6 +1,5 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isTargetSessionBusy } from '@/app/session/hooks/use-prompt-actions/utils'
@@ -8,12 +7,10 @@ import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
import { PRE_TURN_LIVE_SETTLE_GRACE_MS } from './utils'
import { useMessageStream } from './index'
// Per-turn REST amplification guards: session.info must not refetch config for
// background sessions nor invalidate the model-options catalog when the model
// string is merely PRESENT (the backend stamps it on every event) rather than
@@ -21,53 +18,28 @@ import { useMessageStream } from './index'
const ACTIVE_SID = 'session-active'
const ACTIVE_PROFILE = 'compass'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
let hydrateFromStoredSession: ReturnType<typeof vi.fn<() => Promise<void>>>
let queryClient: QueryClient
let sessionStates: Map<string, ClientSessionState> | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
sessionStates = sessionStateByRuntimeIdRef.current
const stream = useMessageStream({
function mountStream() {
stream = renderMessageStream(ACTIVE_SID, {
activeGatewayProfile: ACTIVE_PROFILE,
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
refreshSessions
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
sessionStates = stream.states
}
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
act(() => stream.handleEvent({ payload, session_id: sessionId, type: 'session.info' }))
beforeEach(() => {
handleEvent = null
sessionStates = null
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
@@ -88,7 +60,7 @@ afterEach(() => {
describe('session.info config refetch gating', () => {
it('coalesces active-session bursts into one trailing config fetch', async () => {
// Mount under real timers (waitFor), then freeze time for the debounce.
await mountStream()
mountStream()
vi.useFakeTimers()
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
@@ -105,7 +77,7 @@ describe('session.info config refetch gating', () => {
})
it('never fetches config for a background session heartbeat', async () => {
await mountStream()
mountStream()
vi.useFakeTimers()
sessionInfo('session-background', { model: 'm1', running: true })
@@ -120,8 +92,8 @@ describe('session.info config refetch gating', () => {
})
describe('session.info model-options invalidation gating', () => {
it('skips invalidation when model/provider merely restate the known values', async () => {
await mountStream()
it('skips invalidation when model/provider merely restate the known values', () => {
mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
// Seed the session's cached runtime state.
@@ -135,8 +107,8 @@ describe('session.info model-options invalidation gating', () => {
expect(invalidate).not.toHaveBeenCalled()
})
it('invalidates when the session model actually changes', async () => {
await mountStream()
it('invalidates when the session model actually changes', () => {
mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
@@ -159,10 +131,10 @@ describe('session.info settles a turn that produced no assistant payload', () =>
const busyFor = (sessionId: string) => isTargetSessionBusy(Object.fromEntries(sessionStates!), sessionId, false)
const startTurn = (sessionId: string) =>
act(() => handleEvent!({ payload: {}, session_id: sessionId, type: 'message.start' }))
act(() => stream.handleEvent({ payload: {}, session_id: sessionId, type: 'message.start' }))
it('leaves the session sendable after a started turn ends with no payload', async () => {
await mountStream()
mountStream()
startTurn(ACTIVE_SID)
expect(busyFor(ACTIVE_SID)).toBe(true)
@@ -179,7 +151,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
})
it('keeps waiting when running=false lands before the turn ever started', async () => {
await mountStream()
mountStream()
// submit arms busy/awaitingResponse optimistically — and seeds the visible
// turn clock (turnStartedAt) at Enter — so this heartbeat is the pre-start
@@ -213,7 +185,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
// never fires. Only an app restart cleared it. Past the grace window the
// gateway's running=false is authoritative and must settle the session.
it('settles an armed-but-never-live turn once the pre-start grace expires (#86795)', async () => {
await mountStream()
mountStream()
act(() => {
sessionStates!.set(ACTIVE_SID, {
@@ -240,7 +212,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
// one — submit and the rewind optimistic transforms both seed the clock —
// but a regression that forgets the seed must fail open, not latch).
it('settles an armed turn with no clock instead of latching busy (#86795)', async () => {
await mountStream()
mountStream()
act(() => {
sessionStates!.set(ACTIVE_SID, {
@@ -258,8 +230,8 @@ describe('session.info settles a turn that produced no assistant payload', () =>
expect(busyFor(ACTIVE_SID)).toBe(false)
})
it('un-latches a background session but does not hydrate its transcript', async () => {
await mountStream()
it('un-latches a background session but does not hydrate its transcript', () => {
mountStream()
startTurn('session-background')
sessionInfo('session-background', { running: false })
@@ -273,7 +245,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
})
it('hydrates the foreground transcript once and coalesces the sidebar refresh', async () => {
await mountStream()
mountStream()
startTurn(ACTIVE_SID)
vi.useFakeTimers()
@@ -294,11 +266,11 @@ describe('session.info settles a turn that produced no assistant payload', () =>
describe('message.complete sidebar refresh coalescing', () => {
it('collapses near-simultaneous completions into one refresh', async () => {
await mountStream()
mountStream()
vi.useFakeTimers()
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
act(() => stream.handleEvent({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
act(() => stream.handleEvent({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
expect(refreshSessions).not.toHaveBeenCalled()
@@ -1,6 +1,5 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
@@ -8,7 +7,7 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $sessionStates, $sessionTiles, publishSessionState } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
// `session.reclaimed`: the backend tore down a live session we're still
// holding (idle TTL, LRU cap, WS-orphan reap). Before this event the runtime id
@@ -17,48 +16,18 @@ import { useMessageStream } from './index'
const ACTIVE_SID = 'session-active'
const ACTIVE_PROFILE = 'compass'
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
let queryClient: QueryClient
let wiringCache: Map<string, ClientSessionState>
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
wiringCache = sessionStateByRuntimeIdRef.current
const stream = useMessageStream({
activeGatewayProfile: ACTIVE_PROFILE,
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
refreshHermesConfig: vi.fn<() => Promise<void>>(async () => undefined),
refreshSessions: vi.fn<() => Promise<void>>(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(ACTIVE_SID, { activeGatewayProfile: ACTIVE_PROFILE, queryClient })
wiringCache = stream.states
}
const reclaim = (sessionId: string, reason = 'ws_orphan_reap') =>
act(() =>
handleEvent!({
stream.handleEvent({
payload: { reason, session_id: sessionId, stored_session_id: 'stored-1' },
session_id: '',
type: 'session.reclaimed'
@@ -66,7 +35,6 @@ const reclaim = (sessionId: string, reason = 'ws_orphan_reap') =>
)
beforeEach(() => {
handleEvent = null
queryClient = new QueryClient()
$sessionStates.set({})
$sessionTiles.set([])
@@ -80,8 +48,8 @@ afterEach(() => {
})
describe('session.reclaimed', () => {
it('drops the cached state for the reclaimed runtime', async () => {
await mountStream()
it('drops the cached state for the reclaimed runtime', () => {
mountStream()
publishSessionState('live-gone', createClientSessionState())
expect($sessionStates.get()['live-gone']).toBeDefined()
@@ -90,8 +58,8 @@ describe('session.reclaimed', () => {
expect($sessionStates.get()['live-gone']).toBeUndefined()
})
it('leaves every other live session alone', async () => {
await mountStream()
it('leaves every other live session alone', () => {
mountStream()
publishSessionState('live-gone', createClientSessionState())
publishSessionState('live-kept', createClientSessionState())
@@ -103,8 +71,8 @@ describe('session.reclaimed', () => {
expect($sessionStates.get()['live-kept']).toBeDefined()
})
it('ignores a payload with no runtime id instead of clearing everything', async () => {
await mountStream()
it('ignores a payload with no runtime id instead of clearing everything', () => {
mountStream()
publishSessionState('live-a', createClientSessionState())
publishSessionState('live-b', createClientSessionState())
@@ -114,12 +82,11 @@ describe('session.reclaimed', () => {
expect(Object.keys($sessionStates.get()).sort()).toEqual(['live-a', 'live-b'])
})
it('drops the runtime regardless of which reclaim reason fired', async () => {
it('drops the runtime regardless of which reclaim reason fired', () => {
for (const reason of ['idle_timeout', 'lru_evict', 'ws_orphan_reap']) {
$sessionStates.set({})
cleanup()
handleEvent = null
await mountStream()
mountStream()
publishSessionState('live-gone', createClientSessionState())
reclaim('live-gone', reason)
@@ -132,8 +99,8 @@ describe('session.reclaimed', () => {
// state drop above empties the tile's view, but with `runtimeId` still set
// the tile's resume effect (gated on !runtimeId) never refires — an empty
// transcript under live chrome, unrecoverable without closing the tab.
it('unbinds a tile holding the reclaimed runtime so its resume can refire', async () => {
await mountStream()
it('unbinds a tile holding the reclaimed runtime so its resume can refire', () => {
mountStream()
publishSessionState('live-gone', createClientSessionState('stored-1'))
$sessionTiles.set([
{ runtimeId: 'live-gone', storedSessionId: 'stored-1' },
@@ -152,8 +119,8 @@ describe('session.reclaimed', () => {
// The wiring cache is resumeTile's warm path: a leftover entry for the dead
// runtime would be handed straight back on the re-resume, rebinding the tile
// to the same reclaimed id the backend just forgot.
it('purges the wiring cache entry for the reclaimed runtime', async () => {
await mountStream()
it('purges the wiring cache entry for the reclaimed runtime', () => {
mountStream()
wiringCache.set('live-gone', createClientSessionState('stored-1'))
wiringCache.set('live-kept', createClientSessionState('stored-2'))
@@ -4,56 +4,21 @@
// mid-transcript — the dither block anywhere but the tail. session.info
// running=false is the turn's finally-block signal and the only settle edge
// those paths still emit, so it must finalize the bubble.
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
import { STREAM_DELTA_FLUSH_MS } from './utils'
import { useMessageStream } from './index'
const SID = 'stale-pending-session'
let handleEvent: ((event: RpcEvent) => void) | null = null
let states: Map<string, ClientSessionState>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
states = sessionStateByRuntimeIdRef.current
}, [stream.handleGatewayEvent])
return null
}
let stream: MessageStreamHarness
async function mountHarness() {
vi.useFakeTimers()
render(<Harness />)
stream = renderMessageStream(SID)
await act(async () => {
await Promise.resolve()
})
@@ -65,14 +30,9 @@ const flushDeltas = async () => {
})
}
const emit = (event: RpcEvent) => act(() => handleEvent?.(event))
const emit = (event: RpcEvent) => act(() => stream.handleEvent(event))
describe('turn end without message.complete (session.info running=false)', () => {
beforeEach(() => {
handleEvent = null
states = new Map()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
@@ -86,11 +46,11 @@ describe('turn end without message.complete (session.info running=false)', () =>
emit({ payload: { text: 'partial answer' }, session_id: SID, type: 'message.delta' })
await flushDeltas()
expect(states.get(SID)?.messages.at(-1)?.pending).toBe(true)
expect(stream.state()?.messages.at(-1)?.pending).toBe(true)
emit({ payload: { running: false }, session_id: SID, type: 'session.info' })
const state = states.get(SID)
const state = stream.state()
const tail = state?.messages.at(-1)
expect(tail?.role).toBe('assistant')
@@ -118,7 +78,7 @@ describe('turn end without message.complete (session.info running=false)', () =>
emit({ payload: { running: false }, session_id: SID, type: 'session.info' })
const state = states.get(SID)
const state = stream.state()
// Same math as Stop: an empty-text placeholder is dropped, nothing stays
// pending, and the stream binding is released.
@@ -1,56 +1,14 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { act, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
import { STREAM_DELTA_FLUSH_MS } from './utils'
import { useMessageStream } from './index'
const SID = 'stream-session'
let handleEvent: ((event: RpcEvent) => void) | null = null
let states: Map<string, ClientSessionState>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
states = sessionStateByRuntimeIdRef.current
}, [stream.handleGatewayEvent])
return null
}
let stream: MessageStreamHarness
describe('stream delta delivery', () => {
beforeEach(() => {
handleEvent = null
states = new Map()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
@@ -64,7 +22,7 @@ describe('stream delta delivery', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
vi.useFakeTimers()
render(<Harness />)
stream = renderMessageStream(SID)
await act(async () => {
await Promise.resolve()
})
@@ -72,7 +30,7 @@ describe('stream delta delivery', () => {
// Reach the frame-gated branch: it is only taken once the coalescing floor
// has already elapsed since the previous flush. Send one delta, let it
// flush, then idle past the floor so the NEXT delta schedules immediately.
act(() => handleEvent?.({ payload: { text: 'first ' }, session_id: SID, type: 'message.delta' }))
act(() => stream.handleEvent({ payload: { text: 'first ' }, session_id: SID, type: 'message.delta' }))
await act(async () => {
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS)
})
@@ -80,13 +38,13 @@ describe('stream delta delivery', () => {
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS * 2)
})
act(() => handleEvent?.({ payload: { text: 'and the rest' }, session_id: SID, type: 'message.delta' }))
act(() => stream.handleEvent({ payload: { text: 'and the rest' }, session_id: SID, type: 'message.delta' }))
await act(async () => {
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS)
})
expect(states.get(SID)?.messages.at(-1)?.parts).toMatchObject([{ type: 'text', text: 'first and the rest' }])
expect(stream.state()?.messages.at(-1)?.parts).toMatchObject([{ type: 'text', text: 'first and the rest' }])
// The flush must not have depended on a frame: this mock parks every rAF
// callback, yet the text arrived. runFlush still registers its
// adaptive-floor measurement callback here; that one is allowed to wait
@@ -1,63 +1,29 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { chatMessageText } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
let handleEvent: ((event: RpcEvent) => void) | null = null
let sessionStates: Map<string, ClientSessionState>
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
sessionStates.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
function mountStream() {
stream = renderMessageStream(SID)
}
async function mountStream() {
sessionStates = new Map()
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const start = () => act(() => stream.handleEvent({ payload: {}, session_id: SID, type: 'message.start' }))
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
const delta = (text: string) =>
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.delta' }))
const completeWithError = (payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload: { status: 'error', ...payload }, session_id: SID, type: 'message.complete' }))
act(() => stream.handleEvent({ payload: { status: 'error', ...payload }, session_id: SID, type: 'message.complete' }))
function getState(): ClientSessionState {
return sessionStates.get(SID) ?? createClientSessionState()
return stream.state()
}
function lastAssistant() {
@@ -65,17 +31,13 @@ function lastAssistant() {
}
describe('terminal error message.complete frames', () => {
beforeEach(() => {
handleEvent = null
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('marks the bubble failed from the structured error field, not the text heuristic', async () => {
await mountStream()
mountStream()
await start()
await delta('…')
@@ -89,7 +51,7 @@ describe('terminal error message.complete frames', () => {
})
it('keeps streamed partial text visible on a partial failure', async () => {
await mountStream()
mountStream()
await start()
await delta('half an ans')
@@ -107,7 +69,7 @@ describe('terminal error message.complete frames', () => {
})
it('falls back to the frame text when no error field is present', async () => {
await mountStream()
mountStream()
await start()
await delta('…')
@@ -0,0 +1,117 @@
import { QueryClient } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
export interface MessageStreamHarnessOptions extends Partial<Parameters<typeof useMessageStream>[0]> {
/** Session-state map to mount with, for tests that seed state up front. */
states?: Map<string, ClientSessionState>
}
export interface MessageStreamHarness {
/** Feed a gateway event into the mounted hook. */
handleEvent: (event: RpcEvent) => void
/** Push streaming assistant text, bypassing the event envelope. For the specs
* about flush scheduling rather than about a particular event. */
appendDelta: (sessionId: string, delta: string) => void
/** The hook's session-state map, so callers can seed or inspect it directly. */
states: Map<string, ClientSessionState>
/** State for a session, blank before the hook has written any. */
state: (sessionId?: string) => ClientSessionState
/** Last state written for any session — for assertions about the write itself. */
latest: () => ClientSessionState | null
/** Text of the trailing text part on a session's newest message, '' when the
* tail is not text. What streamed deltas accumulate into. */
text: (sessionId?: string) => string
/** Text of the reasoning part on the newest message, '' when there is none.
* MoA events land there, so the moa specs read the stream through it. */
reasoningText: () => string
}
/** Mount `useMessageStream` with inert dependencies and hand back the event sink
* plus the state it produces.
*
* Every event test needs the same wiring — the real hook, stubbed refresh and
* hydrate callbacks, a session-state map it owns — and differs only in which
* events it sends and what it asserts. `overrides` takes any of the hook's own
* options for the tests that need a seeded state map, a shared query client, a
* named gateway profile, or a callback they can assert against. Callers still
* own `cleanup()`. */
export function renderMessageStream(
sessionId: string | null,
{ states = new Map<string, ClientSessionState>(), ...overrides }: MessageStreamHarnessOptions = {}
): MessageStreamHarness {
let dispatch: ((event: RpcEvent) => void) | null = null
let appendDelta: ((sessionId: string, delta: string) => void) | null = null
let latest: ClientSessionState | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(sessionId)
const sessionStateByRuntimeIdRef = useRef(states)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (id, updater) => {
const next = updater(states.get(id) ?? createClientSessionState())
states.set(id, next)
latest = next
return next
},
...overrides
})
useEffect(() => {
dispatch = stream.handleGatewayEvent
appendDelta = stream.appendAssistantDelta
}, [stream.appendAssistantDelta, stream.handleGatewayEvent])
return null
}
render(<Harness />)
const state = (id = sessionId ?? '') => states.get(id) ?? createClientSessionState()
return {
handleEvent: event => {
if (!dispatch) {
throw new Error('renderMessageStream: the hook never mounted')
}
dispatch(event)
},
appendDelta: (id, delta) => {
if (!appendDelta) {
throw new Error('renderMessageStream: the hook never mounted')
}
appendDelta(id, delta)
},
states,
state,
latest: () => latest,
text: id => {
const part = state(id).messages.at(-1)?.parts.at(-1)
return part?.type === 'text' ? part.text : ''
},
reasoningText: () => {
const part = latest?.messages.at(-1)?.parts.find(p => p.type === 'reasoning')
return part?.type === 'reasoning' ? part.text : ''
}
}
}
@@ -1,57 +1,18 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'timeline-session'
let handleEvent: ((event: RpcEvent) => void) | null = null
let states: Map<string, ClientSessionState>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
states.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
let stream: MessageStreamHarness
const event = (type: string, timestamp: number, payload: Record<string, unknown> = {}) =>
act(() => handleEvent?.({ payload: { ...payload, timestamp }, session_id: SID, type }))
act(() => stream.handleEvent({ payload: { ...payload, timestamp }, session_id: SID, type }))
describe('live transcript timeline events', () => {
beforeEach(async () => {
handleEvent = null
states = new Map()
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
stream = renderMessageStream(SID)
})
afterEach(() => {
@@ -68,7 +29,7 @@ describe('live transcript timeline events', () => {
event('message.delta', 105.625, { text: 'The file looks good.' })
event('message.complete', 106.875, { text: 'The file looks good.' })
const assistants = states.get(SID)?.messages.filter(message => message.role === 'assistant') ?? []
const assistants = stream.state(SID).messages.filter(message => message.role === 'assistant') ?? []
expect(assistants).toHaveLength(2)
expect([assistants[0].timestamp, assistants[0].completedAt]).toEqual([101.125, 101.75])
@@ -88,7 +49,7 @@ describe('live transcript timeline events', () => {
event('message.delta', 202.25, { text: 'Then speaking.' })
event('tool.start', 203.5, { args: {}, name: 'terminal', tool_id: 'call-2' })
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
expect(assistant?.parts.map(part => part.type)).toEqual(['reasoning', 'text', 'tool-call'])
expect(assistant?.parts.map(part => part.timestamp)).toEqual([201.125, 202.25, 203.5])
@@ -98,7 +59,7 @@ describe('live transcript timeline events', () => {
event('message.start', 300)
event('error', 301.875, { error: 'provider failed' })
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
expect(assistant?.error).toBeTruthy()
expect([assistant?.timestamp, assistant?.completedAt]).toEqual([301.875, 301.875])
@@ -107,7 +68,7 @@ describe('live transcript timeline events', () => {
it('uses the gateway event time for a review summary system row', () => {
event('review.summary', 401.625, { text: 'Review saved.' })
const system = states.get(SID)?.messages.find(message => message.role === 'system')
const system = stream.state(SID).messages.find(message => message.role === 'system')
expect(system?.timestamp).toBe(401.625)
expect(system?.parts[0].timestamp).toBe(401.625)
@@ -118,7 +79,7 @@ describe('live transcript timeline events', () => {
event('tool.start', 501, { args: {}, name: 'terminal', tool_id: 'call-3' })
event('session.info', 502.75, { running: false })
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
expect(assistant?.completedAt).toBe(502.75)
expect(assistant?.parts[0].completedAt).toBe(502.75)
@@ -1,59 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { TodoItem } from '@/lib/todos'
import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status })
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
function mountStream() {
stream = renderMessageStream(SID)
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const complete = () => act(() => handleEvent!({ payload: { text: 'done' }, session_id: SID, type: 'message.complete' }))
const complete = () =>
act(() => stream.handleEvent({ payload: { text: 'done' }, session_id: SID, type: 'message.complete' }))
describe('useMessageStream turn-end todo cleanup', () => {
beforeEach(() => {
handleEvent = null
clearSessionTodos(SID)
})
@@ -63,8 +29,8 @@ describe('useMessageStream turn-end todo cleanup', () => {
vi.restoreAllMocks()
})
it('drops a still-active task list when the turn completes', async () => {
await mountStream()
it('drops a still-active task list when the turn completes', () => {
mountStream()
setSessionTodos(SID, [todo('a', 'completed'), todo('b', 'in_progress')])
complete()
@@ -72,8 +38,8 @@ describe('useMessageStream turn-end todo cleanup', () => {
expect($todosBySession.get()[SID]).toBeUndefined()
})
it('keeps a finished list on completion so its linger shows the final checkmarks', async () => {
await mountStream()
it('keeps a finished list on completion so its linger shows the final checkmarks', () => {
mountStream()
setSessionTodos(SID, [todo('a', 'completed')])
complete()
@@ -82,11 +48,11 @@ describe('useMessageStream turn-end todo cleanup', () => {
expect($todosBySession.get()[SID]).toHaveLength(1)
})
it('drops a still-active task list when the turn errors out', async () => {
await mountStream()
it('drops a still-active task list when the turn errors out', () => {
mountStream()
setSessionTodos(SID, [todo('a', 'in_progress')])
act(() => handleEvent!({ payload: { message: 'boom' }, session_id: SID, type: 'error' }))
act(() => stream.handleEvent({ payload: { message: 'boom' }, session_id: SID, type: 'error' }))
expect($todosBySession.get()[SID]).toBeUndefined()
})
@@ -1,6 +1,4 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
@@ -8,7 +6,7 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { $draftingToolSessions } from '@/store/tool-drafting'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
const OTHER_SID = 'session-2'
@@ -16,42 +14,14 @@ const OTHER_SID = 'session-2'
// Module-scoped so a test can seed session state (e.g. interrupted) before the
// handler reads it — `sessionInterrupted` resolves against this map.
const sessionStates = new Map<string, ClientSessionState>()
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(sessionStates)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const next = updater(sessionStates.get(sessionId) ?? createClientSessionState())
sessionStates.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID, { states: sessionStates })
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
act(() => handleEvent!({ payload, session_id: sessionId, type }))
act(() => stream.handleEvent({ payload, session_id: sessionId, type }))
}
function draftedTool(sessionId = SID) {
@@ -60,7 +30,6 @@ function draftedTool(sessionId = SID) {
describe('drafting-tool label lifecycle', () => {
beforeEach(() => {
handleEvent = null
sessionStates.clear()
$draftingToolSessions.set({})
})
@@ -72,8 +41,8 @@ describe('drafting-tool label lifecycle', () => {
vi.restoreAllMocks()
})
it('names the tool the model is drafting', async () => {
await mountStream()
it('names the tool the model is drafting', () => {
mountStream()
emit('tool.generating', { name: 'write_file' })
@@ -92,8 +61,8 @@ describe('drafting-tool label lifecycle', () => {
['tool.complete', { name: 'terminal', tool_id: 'tool-1' }],
['message.complete', { text: 'done' }],
['error', { message: 'boom' }]
] as const)('retires the label when %s proves the model moved on', async (type, payload) => {
await mountStream()
] as const)('retires the label when %s proves the model moved on', (type, payload) => {
mountStream()
emit('tool.generating', { name: 'write_file' })
emit(type, payload)
@@ -101,8 +70,8 @@ describe('drafting-tool label lifecycle', () => {
expect(draftedTool()).toBeUndefined()
})
it('leaves another session’s label alone', async () => {
await mountStream()
it('leaves another session’s label alone', () => {
mountStream()
emit('tool.generating', { name: 'patch' }, OTHER_SID)
emit('tool.generating', { name: 'write_file' })
@@ -113,9 +82,9 @@ describe('drafting-tool label lifecycle', () => {
})
// A stopped turn can still emit a frame or two before the backend notices.
it('ignores a tool announced after the user hit stop', async () => {
it('ignores a tool announced after the user hit stop', () => {
sessionStates.set(SID, { ...createClientSessionState(), interrupted: true })
await mountStream()
mountStream()
emit('tool.generating', { name: 'write_file' })
@@ -1,59 +1,26 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $currentUsage } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
const SID = 'session-1'
// $currentUsage mirrors the primary session; ClientSessionState.usage drives
// the same status bar when a secondary tile is focused.
const BASELINE = { calls: 2, input: 500, output: 40, total: 540 }
let handleEvent: ((event: RpcEvent) => void) | null = null
let stream: MessageStreamHarness
let sessionStates = new Map<string, ClientSessionState>()
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(sessionStates)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
function mountStream() {
stream = renderMessageStream(SID, { states: sessionStates })
}
describe('useMessageStream status-bar usage scoping', () => {
beforeEach(() => {
handleEvent = null
sessionStates = new Map([[SID, { ...createClientSessionState(), usage: { ...BASELINE } }]])
$currentUsage.set({ ...BASELINE })
})
@@ -63,11 +30,11 @@ describe('useMessageStream status-bar usage scoping', () => {
vi.restoreAllMocks()
})
it('merges a live session.usage tick from the focused session', async () => {
await mountStream()
it('merges a live session.usage tick from the focused session', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { usage: { context_percent: 42, input: 1200, total: 1280 } },
session_id: SID,
type: 'session.usage'
@@ -84,11 +51,11 @@ describe('useMessageStream status-bar usage scoping', () => {
})
})
it('caches a background session.usage tick without overwriting the primary status bar', async () => {
await mountStream()
it('caches a background session.usage tick without overwriting the primary status bar', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { usage: { input: 9999, total: 9999 } },
session_id: 'background-session',
type: 'session.usage'
@@ -104,11 +71,11 @@ describe('useMessageStream status-bar usage scoping', () => {
})
})
it('applies message.complete usage from the focused session', async () => {
await mountStream()
it('applies message.complete usage from the focused session', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { text: 'done', usage: { calls: 3, input: 1500, output: 90, total: 1590 } },
session_id: SID,
type: 'message.complete'
@@ -118,11 +85,11 @@ describe('useMessageStream status-bar usage scoping', () => {
expect($currentUsage.get()).toEqual({ calls: 3, input: 1500, output: 90, total: 1590 })
})
it('ignores message.complete usage from a background session', async () => {
await mountStream()
it('ignores message.complete usage from a background session', () => {
mountStream()
act(() =>
handleEvent!({
stream.handleEvent({
payload: { text: 'done', usage: { calls: 9, input: 9999, output: 999, total: 9999 } },
session_id: 'background-session',
type: 'message.complete'
@@ -16,21 +16,13 @@ import {
} from '@/store/session'
import type * as SessionStates from '@/store/session-states'
import { deferred } from '../../../test/deferred'
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setApiRequestProfile: vi.fn(),
@@ -139,7 +139,7 @@ describe('truncateSubmitParams', () => {
})
it('drops renderer-synthetic message ids but keeps durable row ids', () => {
// chat-messages.ts: `${timestamp}-${index}-${role}`
// chat-messages/hydration.ts: `${timestamp}-${index}-${role}`
expect(truncateSubmitParams(1, '1723456789-0-user', 456)).toEqual({
confirm_truncate: true,
truncate_before_user_ordinal: 1,
@@ -490,7 +490,7 @@ describe('runRewindSubmit durable-address discipline (#87059)', () => {
})
describe('optimistic rewind/reload turn-clock seeding (#86795)', () => {
// The no-payload settle gate in gateway-event.ts holds a running=false
// The no-payload settle gate in gateway-event/session-info.ts holds a running=false
// heartbeat off while an optimistically armed turn waits for the backend —
// but only for a bounded grace window measured from turnStartedAt. These
// transforms are the arm sites for restore/edit/regenerate, so they must
@@ -94,7 +94,7 @@ export function rebindSurvivorRowIds(messages: ChatMessage[], survivorRowIds: Su
/**
* Renderer-synthetic message ids (`${timestamp}-${index}-${role}` from
* chat-messages.ts, plus older `user-…` / `assistant-…` shapes). Gateway
* chat-messages/hydration.ts, plus older `user-…` / `assistant-…` shapes). Gateway
* history never carries them — only durable `row_id` / platform message_id.
*/
export function isSyntheticRendererId(messageId: string | undefined): boolean {
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store'
import {
deleteSession,
getAllSessionMessages,
getLatestSessionMessages,
getSession,
@@ -56,6 +57,7 @@ import {
import { $sessionTiles } from '@/store/session-states'
import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json'
import { deferred } from '../../../test/deferred'
import { sessionRoute } from '../../routes'
import type { ClientSessionState } from '../../types'
@@ -92,19 +94,9 @@ vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({
const RUNTIME_SESSION_ID = 'rt-new-001'
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
type HarnessHandle = Pick<
ReturnType<typeof useSessionActions>,
'createBackendSessionForSend' | 'selectSidebarItem' | 'startFreshSessionDraft'
'createBackendSessionForSend' | 'removeSession' | 'selectSidebarItem' | 'startFreshSessionDraft'
>
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
@@ -127,19 +119,23 @@ function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
function Harness({
activeSessionId = null,
navigate = vi.fn(),
onReady,
requestGateway
requestGateway,
selectedStoredSessionId = null
}: {
activeSessionId?: null | string
navigate?: ReturnType<typeof vi.fn>
onReady: (handle: HarnessHandle) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionId?: null | string
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
activeSessionId,
activeSessionIdRef: ref(activeSessionId),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
@@ -149,8 +145,8 @@ function Harness({
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
selectedStoredSessionId,
selectedStoredSessionIdRef: ref(selectedStoredSessionId),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
@@ -163,6 +159,55 @@ function Harness({
return null
}
describe('connection-qualified session deletion', () => {
afterEach(() => {
cleanup()
setSessions([])
vi.restoreAllMocks()
})
it('deletes a registry session through its captured connection owner', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
let actions: HarnessHandle | null = null
setSessions([
storedSession({
connection_id: 'source-a',
id: 'shared-session',
profile: 'worker'
})
])
vi.mocked(deleteSession).mockResolvedValue({ ok: true })
vi.mocked(requestGatewayForAgent).mockResolvedValue({} as never)
render(
<Harness
activeSessionId="runtime-shared"
onReady={value => { actions = value }}
requestGateway={requestGateway}
selectedStoredSessionId="shared-session"
/>
)
await waitFor(() => expect(actions).not.toBeNull())
await act(async () => {
await actions?.removeSession('shared-session')
})
expect(deleteSession).toHaveBeenCalledWith('shared-session', {
connectionId: 'source-a',
profile: 'worker'
})
expect(requestGatewayForAgent).toHaveBeenCalledWith(
'source-a',
'worker',
'session.close',
{ session_id: 'runtime-shared' }
)
expect(requestGateway).not.toHaveBeenCalledWith('session.close', expect.anything())
})
})
function StoredIdRotationHarness({
activeSessionIdRef,
getRoutedStoredSessionId,
@@ -1761,6 +1761,12 @@ export function useSessionActions({
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
const previousPinned = $pinnedSessionIds.get()
const removedOwner: SessionOwnerScope = removed?.connection_id
? {
connectionId: removed.connection_id,
profile: removed.profile || 'default'
}
: removed?.profile
// Pins are keyed on the durable lineage-root id; the stored id may be the
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
@@ -1783,10 +1789,15 @@ export function useSessionActions({
try {
if (closingRuntimeId) {
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
await requestForSessionProfile(
removedOwner,
requestGateway,
'session.close',
{ session_id: closingRuntimeId }
).catch(() => undefined)
}
await deleteSession(storedSessionId, removed?.profile)
await deleteSession(storedSessionId, removedOwner)
// A deleted session's cached tail must not resurrect on a recycled id.
dropTranscriptTail(storedSessionId)
// Only after the RPC lands — the optimistic eviction above can roll
@@ -62,31 +62,33 @@ describe('resolveStoredSession profile ownership', () => {
it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => {
$sessions.set([session({ id: 's1' })])
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
const resolved = await resolveStoredSession('s1')
expect(resolved?.profile).toBe('default')
// rung 2 (bare) then rung 3 (stamped probes: active profile first)
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
expect(mockGetSession).toHaveBeenNthCalledWith(3, 's1', 'default')
// rung 2 (active profile) then rung 3 (stamped cross-profile probe)
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1', 'meta')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'default')
})
it('probes the ACTIVE profile when the unscoped lookup 404s (hidden Bot Mode chat)', async () => {
// A hidden Bot Mode canonical chat is never in the sidebar cache, and the
// unscoped GET routes to the PRIMARY backend (404). The owner is the
// active profile itself — it must be probed, not skipped.
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', message_count: 2 }))
it('scopes the first by-id lookup so a miss does not skip the active profile', async () => {
$activeGatewayProfile.set('brain')
$profiles.set(profiles('default', 'brain'))
mockGetSession.mockImplementation(async (id, profile) => {
if (profile === 'brain') {
return session({ id, profile: 'brain' })
}
throw new Error('404: Session not found')
})
const resolved = await resolveStoredSession('s1')
expect(resolved?.profile).toBe('meta')
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta')
expect(resolved?.profile).toBe('brain')
expect(mockGetSession).toHaveBeenCalledWith('s1', 'brain')
expect(mockGetSession).not.toHaveBeenCalledWith('s1')
expect(mockGetSession).not.toHaveBeenCalledWith('s1', 'default')
})
it('accepts a profile-less cache hit for single-profile users', async () => {
@@ -105,6 +107,7 @@ describe('resolveStoredSession profile ownership', () => {
const resolved = await resolveStoredSession('s1')
expect(resolved?.profile).toBe('meta')
expect(mockGetSession).toHaveBeenCalledWith('s1', 'meta')
// the upserted cache row is owned too, so the next hit short-circuits
expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta')
})
@@ -113,7 +116,6 @@ describe('resolveStoredSession profile ownership', () => {
// Per-profile remote override: Electron strips the desktop alias before
// forwarding, so the standalone backend stamps its backend-local root.
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
$activeGatewayProfile.set('default')
$profiles.set(profiles('default', 'meta'))
@@ -125,7 +127,6 @@ describe('resolveStoredSession profile ownership', () => {
})
it('stamps the probed profile on a scoped hit from an older backend that omits it', async () => {
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1' }))
@@ -137,7 +138,6 @@ describe('resolveStoredSession profile ownership', () => {
})
it('resolveSessionProfile routes a default-profile session from a non-default gateway', async () => {
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
@@ -1338,17 +1338,19 @@ export async function resolveStoredSession(
return cached
}
// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
try {
const session = await getSession(storedSessionId)
// Direct by-id on the active profile — one row lookup, no list scan. Electron
// routes an unscoped GET to the primary backend, which may not own the
// active profile. A 404 there used to skip that profile in the probes below,
// so the session was never found.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
// Older backends omit `profile` on unscoped GETs; the serving backend is
// the active gateway's, so back-fill that rather than caching an unowned
// row. A present stamp is preserved: in app-global remote mode a bare hit
// can legitimately carry another profile's row (see the branch tests).
session.profile ||= normalizeProfileKey($activeGatewayProfile.get())
try {
const session = await getSession(storedSessionId, activeKey)
// Older backends can omit `profile`; this request targeted the active
// profile, so back-fill that rather than caching an unowned row. A present
// stamp is preserved for backend compatibility.
session.profile ||= activeKey
upsertResolvedSession(session, storedSessionId)
@@ -1357,30 +1359,16 @@ export async function resolveStoredSession(
// Not on the active profile — fall through to the cross-profile probe.
}
// Multi-profile only: probe each profile by id (still one cheap lookup
// each) rather than pulling every profile's recent sessions. The first hit
// carries its owning `profile`, which routes the resume to the right backend.
//
// The ACTIVE profile is probed too, and first. The unscoped rung above does
// NOT cover it: Electron routes a profile-less /api/sessions GET to the
// PRIMARY backend, not the active gateway's, so a session owned by the
// active non-primary profile 404s there. Skipping the active key then left
// it as the ONE profile never probed — a hidden Bot Mode chat (never in the
// sidebar cache) owned by the focused bot resolved to undefined on every
// switch, the transcript prefetch went unscoped to the primary, and the
// thread painted empty until an explicitly-profiled row (right-click →
// Sessions) seeded the cache.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
// Multi-profile only: probe each remaining profile by id (still one cheap
// lookup each) rather than pulling every profile's recent sessions. The
// first hit carries its owning `profile`, which routes the resume to the
// right backend. The active profile was already tried above.
const otherProfiles = $profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
const probeProfiles = [
activeKey,
...$profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
]
for (const profile of probeProfiles) {
for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)
@@ -18,6 +18,8 @@ import {
setSessionsLoading
} from '@/store/session'
import { deferred } from '../../../test/deferred'
import { useSessionListActions } from './use-session-list-actions'
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
@@ -68,15 +70,6 @@ interface Deferred<T> {
}
/** Create a promise whose completion order the stale-response tests control. */
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
@@ -9,18 +9,10 @@ import {
setNewChatWorkspaceTarget
} from '@/store/session'
import { deferred } from '../../test/deferred'
import { startWorkspaceSession } from './workspace-session-target'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(done => {
resolve = done
})
return { promise, resolve }
}
describe('startWorkspaceSession', () => {
afterEach(() => {
setCurrentBranch('')

Some files were not shown because too many files have changed in this diff Show More