Compare commits

..

1 Commits

Author SHA1 Message Date
ethernet
4312a9cc4e test ci 2026-06-12 15:02:05 -04:00
22 changed files with 3920 additions and 344 deletions

49
.github/workflows/e2e-cli-install.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: E2E CLI Tests
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
e2e-tui-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: cd e2e && CI=true npm run test
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
- name: Bundle TUI traces into self-contained replay HTML
if: always()
run: node e2e/scripts/bundle-replay-html.mjs
- name: Upload TUI replay viewer
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-replay-viewer
path: tui-replay-viewer/
retention-days: 7
- name: Upload raw TUI test traces
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-test-traces
path: e2e/tui-traces/
retention-days: 7

2
.gitignore vendored
View File

@@ -19,6 +19,8 @@ __pycache__/
.notebooklm-playwright/
.pip-cache/
.uv-cache/
.tui-test/
tui-traces/
compose.hermes.local.yml
export*
__pycache__/model_tools.cpython-310.pyc

View File

@@ -1,87 +0,0 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const http = require('node:http')
const https = require('node:https')
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
function fetchPublicText(url, options = {}) {
return new Promise((resolve, reject) => {
let parsed
try {
parsed = new URL(url)
} catch (error) {
reject(new Error(`Invalid URL: ${error.message}`))
return
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
return
}
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const req = client.request(parsed, { method: options.method || 'GET' }, res => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
if ((res.statusCode || 500) >= 400) {
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
return
}
resolve(text)
})
})
req.on('error', reject)
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
req.end()
})
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
resolveServedDashboardToken
}

View File

@@ -1,89 +0,0 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})

View File

@@ -29,7 +29,6 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { resolveServedDashboardToken } = require('./dashboard-token.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
@@ -4595,20 +4594,15 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
return token
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
token,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4827,10 +4821,6 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
return token
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4844,8 +4834,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}

View File

@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {

View File

@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

14
e2e/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "hermes-agent-e2e",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "npm exec @microsoft/tui-test -t",
"replay": "npm exec @microsoft/tui-test show-trace"
},
"devDependencies": {
"@microsoft/tui-test": "^0.0.4",
"tui-replay": "^0.4.3"
}
}

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Bundle tui-replay traces into a single self-contained HTML file.
*
* Run from the repo root after e2e tests complete:
* node e2e/scripts/bundle-replay-html.mjs
*
* Input: e2e/tui-traces/ (default @microsoft/tui-test output dir)
* Output: tui-replay-viewer/replay.html (uploaded as a GHA artifact)
*/
import { createReplayDataSource } from 'tui-replay';
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
// tui-replay/dist/ — resolved via ESM so package exports are honoured
const tuiReplayDist = dirname(fileURLToPath(import.meta.resolve('tui-replay')));
const tracesDir = resolve(repoRoot, 'e2e/tui-traces');
const outputDir = resolve(repoRoot, 'tui-replay-viewer');
const outputFile = join(outputDir, 'replay.html');
// ── exact strings to patch in client.js ────────────────────────────────────
const SELECTORS_IMPORT =
'import { annotationsForFrame, frameIndexAtTime, timelineItems } from "../preview/selectors.js";';
// Lines 166-172 of dist/viewer/client.js (0.4.x)
const FETCH_ORIGINAL = `async function fetchPreviewModel() {
const response = await fetch("/api/traces");
if (!response.ok) {
throw new Error(\`Unable to load traces: \${response.status}\`);
}
return (await response.json());
}`;
const FETCH_PATCHED = `async function fetchPreviewModel() {
return __INLINE_MODEL__;
}`;
// Lines 140-149 of dist/viewer/client.js (0.4.x)
const CONNECT_ORIGINAL = `function connectLiveUpdates() {
if (!("EventSource" in window)) {
startPollingLiveUpdates();
return;
}
const events = new EventSource("/api/events");
events.addEventListener("model", (event) => {
applyModelUpdate(JSON.parse(event.data));
});
}`;
const CONNECT_PATCHED = `function connectLiveUpdates() {
/* static mode: no live updates */
}`;
// ───────────────────────────────────────────────────────────────────────────
async function main() {
// Gracefully skip when traces haven't been written yet (e.g. tests skipped)
try {
await access(tracesDir);
} catch {
console.log(`tui-traces dir not found at ${tracesDir} — skipping HTML bundle.`);
process.exit(0);
}
console.log(`Loading traces from ${tracesDir}`);
const dataSource = createReplayDataSource({
inputs: [tracesDir],
projectRoot: repoRoot,
});
const model = await dataSource.load();
if (model.traces.length === 0) {
console.log('No traces found — skipping HTML bundle.');
process.exit(0);
}
console.log(`Found ${model.traces.length} trace(s).`);
// ── Load tui-replay dist assets ──────────────────────────────────────────
// renderIndexHtml is internal (not in the public index.js export) so we
// import it directly from the dist path.
const { renderIndexHtml } = await import(
pathToFileURL(join(tuiReplayDist, 'server/html.js')).href
);
const [rawClientJs, rawSelectorsJs] = await Promise.all([
readFile(join(tuiReplayDist, 'viewer/client.js'), 'utf8'),
readFile(join(tuiReplayDist, 'preview/selectors.js'), 'utf8'),
]);
// ── Patch client.js for static/embedded use ──────────────────────────────
let clientJs = rawClientJs;
// 1. Remove the ES module import (selectors will be inlined above it)
if (!clientJs.includes(SELECTORS_IMPORT)) {
throw new Error(
'Could not find selectors import in client.js — tui-replay may have updated. ' +
'Please update the SELECTORS_IMPORT constant in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(SELECTORS_IMPORT + '\n', '');
// 2. Replace the live fetch with a return of the inlined model
if (!clientJs.includes(FETCH_ORIGINAL)) {
throw new Error(
'Could not find fetchPreviewModel body in client.js — tui-replay may have updated. ' +
'Please update FETCH_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(FETCH_ORIGINAL, FETCH_PATCHED);
// 3. Disable live-reload SSE/polling (no server in static mode)
if (!clientJs.includes(CONNECT_ORIGINAL)) {
throw new Error(
'Could not find connectLiveUpdates body in client.js — tui-replay may have updated. ' +
'Please update CONNECT_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(CONNECT_ORIGINAL, CONNECT_PATCHED);
// Strip sourcemap comment (optional — keeps file clean in artifact viewer)
clientJs = clientJs.replace(/\n\/\/#\s*sourceMappingURL=client\.js\.map\s*$/, '');
// ── Prepare selectors for inline use ─────────────────────────────────────
// Remove `export` keyword so the functions are available in the same
// module scope as client.js (they're no longer imported — they're just
// declared above client.js in the same <script type="module"> block).
const selectorsInline = rawSelectorsJs
.replace(/^export function /gm, 'function ')
.replace(/\n\/\/#\s*sourceMappingURL=selectors\.js\.map\s*$/, '');
// ── Embed model JSON ──────────────────────────────────────────────────────
// JSON.stringify is safe inside a JS string but escape </script> sequences
// just in case trace content contains them.
const modelJsonString = JSON.stringify(model).replace(/<\/script>/gi, '<\\/script>');
// ── Assemble HTML ─────────────────────────────────────────────────────────
const htmlTemplate = renderIndexHtml();
const SCRIPT_TAG = '<script type="module" src="/assets/client.js"></script>';
if (!htmlTemplate.includes(SCRIPT_TAG)) {
throw new Error(
'Could not find the client script tag in the HTML template — ' +
'tui-replay may have updated. Please update SCRIPT_TAG in bundle-replay-html.mjs.'
);
}
const inlinedHtml = htmlTemplate.replace(
SCRIPT_TAG,
`<script type="module">
/* tui-replay selectors (inlined) */
${selectorsInline}
/* trace model (embedded at bundle time) */
const __INLINE_MODEL__ = JSON.parse(${JSON.stringify(modelJsonString)});
/* tui-replay client (patched for static mode) */
${clientJs}
</script>`
);
// ── Write output ──────────────────────────────────────────────────────────
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, inlinedHtml, 'utf8');
const sizeKb = (Buffer.byteLength(inlinedHtml, 'utf8') / 1024).toFixed(1);
console.log(`✓ Wrote ${outputFile} (${sizeKb} KB, ${model.traces.length} trace(s))`);
}
main().catch((err) => {
console.error('bundle-replay-html failed:', err.message ?? err);
process.exit(1);
});

30
e2e/test/cli.test.ts Normal file
View File

@@ -0,0 +1,30 @@
// import { test, expect } from "@microsoft/tui-test";
// import {mkdtempSync, rmSync} from "fs"
// const CTRL_C = "\x03";
// test.describe("Hermes CLI basics", () => {
// const HERMES_HOME = mkdtempSync("hermes-home")
// test.use({
// env: {HERMES_HOME},
// })
// test("hermes command is available and shows version", async ({ terminal }) => {
// terminal.write("hermes --version\n");
// // Wait for the version output to appear
// await expect(terminal.getByText(/hermes/gi, { full: false })).toBeVisible({ timeout: 15000 });
// });
// test("hermes setup wizard starts interactively", async ({ terminal }) => {
// terminal.write("hermes setup\n");
// // Wait for the wizard to start (e.g., looking for "Configure Hermes Agent" or similar)
// await expect(terminal.getByText(/configure|setup|wizard|api key/gi)).toBeVisible({ timeout: 15000 });
// // Wait for the abort/exit message (KeyboardInterrupt is what python emits on ctrl+c)
// await expect(terminal.getByText(/abort|cancel|exit|terminated|keyboardinterrupt/gi)).toBeVisible({ timeout: 5000 });
// });
// test.afterAll(() => {
// rmSync(HERMES_HOME, { force: true,recursive: true})
// })
// });

24
e2e/test/install.test.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test, expect, Shell } from "@microsoft/tui-test";
import {mkdtempSync, rmSync} from "fs"
if(process.env.CI === "true") {
test.describe("install hermes", () => {
const HERMES_HOME = mkdtempSync("hermes-home")
test.use({
shell: Shell.Bash,
env: {HERMES_HOME},
})
test("hermes installer works", async ({ terminal }) => {
// simulate curl | bash for installer script
terminal.write("cat $GITHUB_WORKSPACE/scripts/install.sh | bash\n");
// Wait for the version output to appear
await expect(terminal.getByText(/asdfasdfasdf/gi, { full: false })).toBeVisible({ timeout: 150000 });
});
test.afterAll(() => {
rmSync(HERMES_HOME, { force: true,recursive: true})
})
});
}

View File

@@ -10332,8 +10332,6 @@ def cmd_dashboard(args):
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
# Desktop pool backends are intentionally per-profile.
and os.environ.get("HERMES_DESKTOP") != "1"
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
@@ -10433,26 +10431,6 @@ def cmd_dashboard(args):
# the missing-provider state if it matters.
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
# agents via tui_gateway.server._make_agent. That path only snapshots the
# tool registry — it never starts MCP discovery (the stdio TUI does that in
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
# this, a profile's configured MCP servers never connect, so desktop
# sessions show no MCP tools. Spawn discovery in the background here so a
# slow/dead server can't block dashboard startup.
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger,
thread_name="dashboard-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at dashboard startup",
exc_info=True,
)
from hermes_cli.web_server import start_server
# The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always

View File

@@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-jN6rD+vVhTCWz3lFZzlmFYXmcMRPTtYWy3XVSiDYbvM=";
npmDepsHash = "sha256-xs98fk+09BWHqq9fsjtGWD23BOVRzfFmRwnOVvv6lv8=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;

1792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"apps/*",
"ui-tui",
"ui-tui/packages/*",
"web"
"web",
"e2e"
],
"scripts": {
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'",

View File

@@ -272,9 +272,8 @@ def main():
# (well above current catalog size) lets the full catalog land in the
# index instead of being truncated at an arbitrary build-time limit.
SOURCE_LIMITS = {
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
# limit bounds the walk and also enables the interactive 12s budget.
"clawhub": 0,
# ClawHub had 49,698+ skills as of May 2026; 200k leaves headroom.
"clawhub": 200_000,
"lobehub": 100_000,
"browse-sh": 5_000,
"claude-marketplace": 5_000,

View File

@@ -82,29 +82,6 @@ class TestUnifiedDashboardRouting:
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch):
"""A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT
reroute into the machine dashboard. The reroute re-execs as the default
profile and exits, so the desktop never sees a ready backend → boot
loop. The guard keeps desktop pool backends per-profile."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or False,
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
assert execs == []
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
@@ -151,45 +128,3 @@ class TestUnifiedDashboardRouting:
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []
def test_dashboard_starts_mcp_discovery_for_ws_backend(self, main_mod, monkeypatch):
"""The dashboard process serves the /api/ws gateway but never runs
tui_gateway/entry.py, so it must kick off MCP discovery itself or
desktop sessions never see a profile's MCP tools."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.delenv("HERMES_WEB_DIST", raising=False)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None)
monkeypatch.setattr(main_mod, "_build_web_ui", lambda *_a, **_k: True)
monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace())
monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"hermes_logging",
types.SimpleNamespace(setup_logging=lambda **_k: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
calls = []
monkeypatch.setattr(
"hermes_cli.mcp_startup.start_background_mcp_discovery",
lambda **kwargs: calls.append(kwargs),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.web_server",
types.SimpleNamespace(start_server=lambda **_kwargs: None),
)
main_mod.cmd_dashboard(_args())
assert calls == [
{
"logger": main_mod.logger,
"thread_name": "dashboard-mcp-discovery",
}
]

View File

@@ -6096,24 +6096,6 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
assert mock_agent.call_args.kwargs["max_iterations"] == 200
def test_make_agent_waits_for_shared_mcp_discovery(monkeypatch):
_setup_make_agent_mocks(monkeypatch, {})
waited = []
from hermes_cli import mcp_startup
monkeypatch.setattr(
mcp_startup,
"wait_for_mcp_discovery",
lambda timeout=0.75: waited.append(timeout),
)
with patch("run_agent.AIAgent"):
server._make_agent("sid1", "key1")
assert waited == [0.75]
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
_setup_make_agent_mocks(
monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}

View File

@@ -381,10 +381,9 @@ class TestClawHubSource(unittest.TestCase):
mock_get.side_effect = side_effect
# Force the deadline to be in the past immediately. Budget only applies
# to bounded browse walks (max_items > 0), not the index builder path.
# Force the deadline to be in the past immediately.
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
results = self.src._load_catalog_index(max_items=10)
results = self.src._load_catalog_index()
# Walk broke well before the 750-page cap.
self.assertLess(page_calls["n"], 750)
@@ -481,23 +480,6 @@ class TestClawHubCatalogWalkBounded(unittest.TestCase):
# Partial (bounded) walk must not be cached.
mock_write_cache.assert_not_called()
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_max_items_zero_ignores_wall_clock_budget(
self, mock_get, _mock_read_cache, _mock_write_cache
):
"""Index builder path (max_items=0) must not truncate on the browse budget."""
page_calls = {"n": 0}
mock_get.side_effect = self._infinite_pages(page_calls)
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
results = self.src._load_catalog_index(max_items=0)
# No budget -> walks until the 750-page safety cap, not ~14 pages in 12s.
self.assertEqual(page_calls["n"], 750)
self.assertEqual(len(results), 750)
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")

View File

@@ -2279,20 +2279,12 @@ class ClawHubSource(SkillSource):
# terminates well before this on `nextCursor` going None — the cap is
# a safety rail against an infinite-cursor loop.
max_pages = 750
# Wall-clock budget is for interactive browse (max_items > 0) only.
# The offline index builder passes max_items=0 and must walk the full
# catalog — a 12s cap there ships ~3k skills and trips the deploy
# health floor (20k).
deadline = (
time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
if max_items > 0
else None
)
deadline = time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
hit_deadline = False
hit_max_items = False
for _ in range(max_pages):
if deadline is not None and time.monotonic() > deadline:
if time.monotonic() > deadline:
hit_deadline = True
break
params: Dict[str, Any] = {"limit": 200}

File diff suppressed because one or more lines are too long

View File

@@ -3072,17 +3072,11 @@ def _make_agent(
from hermes_cli.runtime_provider import resolve_runtime_provider
# MCP tool discovery runs in a background daemon thread at startup so a
# dead server can't freeze the shell. The agent snapshots its tool list
# once here and never re-reads it, so briefly wait for in-flight discovery
# to land before building — bounded, so a slow/dead server still can't
# block. Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps
# its existing tui_gateway.entry-owned thread.
try:
from hermes_cli.mcp_startup import wait_for_mcp_discovery
wait_for_mcp_discovery()
except Exception:
pass
# dead server can't freeze the shell (see tui_gateway/entry.py). The agent
# snapshots its tool list once here and never re-reads it, so briefly wait
# for in-flight discovery to land before building — bounded, so a slow/dead
# server still can't block. No-op once discovery has finished (every build
# after the first during a slow startup).
try:
from tui_gateway.entry import wait_for_mcp_discovery