feat: add tests and update mds

This commit is contained in:
Brooklyn Nicholson 2026-04-08 19:31:25 -05:00
parent f226e6be10
commit 9d8f9765c1
11 changed files with 6013 additions and 4 deletions

View file

@ -56,6 +56,18 @@ hermes-agent/
│ ├── run.py # Main loop, slash commands, message dispatch │ ├── run.py # Main loop, slash commands, message dispatch
│ ├── session.py # SessionStore — conversation persistence │ ├── session.py # SessionStore — conversation persistence
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal │ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
│ ├── src/entry.tsx # TTY gate + render()
│ ├── src/app.tsx # Main state machine and UI
│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge
│ ├── src/components/ # Ink components (branding, markdown, prompts, etc.)
│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue
│ └── src/lib/ # Pure helpers (history, osc52, text)
├── tui_gateway/ # Python JSON-RPC backend for Ink TUI
│ ├── entry.py # stdio entrypoint
│ ├── server.py # RPC handlers and session logic
│ ├── render.py # Optional rich/ANSI bridge
│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration) ├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
├── cron/ # Scheduler (jobs.py, scheduler.py) ├── cron/ # Scheduler (jobs.py, scheduler.py)
├── environments/ # RL training environments (Atropos) ├── environments/ # RL training environments (Atropos)
@ -179,6 +191,58 @@ if canonical == "mycommand":
--- ---
## TUI Architecture (ui-tui + tui_gateway)
The Ink TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
### Process Model
```
hermes --tui
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
│ └─ AIAgent + tools + sessions
└─ renders transcript, composer, prompts, activity
```
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
### Transport
Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
### Key Surfaces
| Surface | Ink component | Gateway method |
|---------|---------------|----------------|
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `activityLane.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
### Slash Command Flow
1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
### Dev Commands
```bash
cd ui-tui
npm install # first time
npm run dev # watch mode
npm start # production
npm run build # typecheck
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
```
---
## Adding New Tools ## Adding New Tools
Requires changes in **3 files**: Requires changes in **3 files**:

View file

View file

@ -0,0 +1,187 @@
"""Tests for tui_gateway JSON-RPC protocol plumbing."""
import io
import json
import sys
import threading
from unittest.mock import MagicMock, patch
import pytest
_original_stdout = sys.stdout
@pytest.fixture(autouse=True)
def _restore_stdout():
yield
sys.stdout = _original_stdout
@pytest.fixture()
def server():
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._methods.clear()
importlib.reload(mod)
@pytest.fixture()
def capture(server):
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
buf = io.StringIO()
server._real_stdout = buf
return server, buf
# ── JSON-RPC envelope ────────────────────────────────────────────────
def test_unknown_method(server):
resp = server.handle_request({"id": "1", "method": "bogus"})
assert resp["error"]["code"] == -32601
def test_ok_envelope(server):
assert server._ok("r1", {"x": 1}) == {
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
}
def test_err_envelope(server):
assert server._err("r2", 4001, "nope") == {
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
}
# ── write_json ───────────────────────────────────────────────────────
def test_write_json(capture):
server, buf = capture
assert server.write_json({"test": True})
assert json.loads(buf.getvalue()) == {"test": True}
def test_write_json_broken_pipe(server):
class _Broken:
def write(self, _): raise BrokenPipeError
def flush(self): raise BrokenPipeError
server._real_stdout = _Broken()
assert server.write_json({"x": 1}) is False
# ── _emit ────────────────────────────────────────────────────────────
def test_emit_with_payload(capture):
server, buf = capture
server._emit("test.event", "s1", {"key": "val"})
msg = json.loads(buf.getvalue())
assert msg["method"] == "event"
assert msg["params"]["type"] == "test.event"
assert msg["params"]["session_id"] == "s1"
assert msg["params"]["payload"]["key"] == "val"
def test_emit_without_payload(capture):
server, buf = capture
server._emit("ping", "s2")
assert "payload" not in json.loads(buf.getvalue())["params"]
# ── Blocking prompt round-trip ───────────────────────────────────────
def test_block_and_respond(capture):
server, _ = capture
result = [None]
threading.Thread(
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
).start()
for _ in range(100):
if server._pending:
break
threading.Event().wait(0.01)
rid = next(iter(server._pending))
server._answers[rid] = "my_answer"
server._pending[rid].set()
threading.Event().wait(0.1)
assert result[0] == "my_answer"
def test_clear_pending(server):
ev = threading.Event()
server._pending["r1"] = ev
server._clear_pending()
assert ev.is_set()
assert server._answers["r1"] == ""
# ── Session lookup ───────────────────────────────────────────────────
def test_sess_missing(server):
_, err = server._sess({"session_id": "nope"}, "r1")
assert err["error"]["code"] == 4001
def test_sess_found(server):
server._sessions["abc"] = {"agent": MagicMock()}
s, err = server._sess({"session_id": "abc"}, "r1")
assert s is not None
assert err is None
# ── Config I/O ───────────────────────────────────────────────────────
def test_config_load_missing(server, tmp_path):
server._hermes_home = tmp_path
assert server._load_cfg() == {}
def test_config_roundtrip(server, tmp_path):
server._hermes_home = tmp_path
server._save_cfg({"model": "test/model"})
assert server._load_cfg()["model"] == "test/model"
# ── _cli_exec_blocked ────────────────────────────────────────────────
@pytest.mark.parametrize("argv", [
[],
["setup"],
["gateway"],
["sessions", "browse"],
["config", "edit"],
])
def test_cli_exec_blocked(server, argv):
assert server._cli_exec_blocked(argv) is not None
@pytest.mark.parametrize("argv", [
["version"],
["sessions", "list"],
])
def test_cli_exec_allowed(server, argv):
assert server._cli_exec_blocked(argv) is None

View file

@ -0,0 +1,67 @@
"""Tests for tui_gateway.render — rendering bridge fallback behavior."""
from unittest.mock import MagicMock, patch
from tui_gateway.render import make_stream_renderer, render_diff, render_message
def _stub_rich(mock_mod):
return patch.dict("sys.modules", {"agent.rich_output": mock_mod})
def _no_rich():
return patch.dict("sys.modules", {"agent.rich_output": None})
# ── render_message ───────────────────────────────────────────────────
def test_render_message_none_without_module():
with _no_rich():
assert render_message("hello") is None
def test_render_message_formatted():
mod = MagicMock()
mod.format_response.return_value = "<b>hi</b>"
with _stub_rich(mod):
assert render_message("hi", 100) == "<b>hi</b>"
def test_render_message_type_error_fallback():
mod = MagicMock()
mod.format_response.side_effect = [TypeError, "fallback"]
with _stub_rich(mod):
assert render_message("hi") == "fallback"
def test_render_message_exception_returns_none():
mod = MagicMock()
mod.format_response.side_effect = RuntimeError
with _stub_rich(mod):
assert render_message("hi") is None
# ── render_diff / make_stream_renderer ───────────────────────────────
def test_render_diff_none_without_module():
with _no_rich():
assert render_diff("+line") is None
def test_stream_renderer_none_without_module():
with _no_rich():
assert make_stream_renderer() is None
def test_stream_renderer_returns_instance():
renderer = MagicMock()
mod = MagicMock()
mod.StreamingRenderer.return_value = renderer
with _stub_rich(mod):
assert make_stream_renderer(120) is renderer

View file

@ -294,5 +294,4 @@ tui_gateway/
## Notes ## Notes
- `src/main.tsx` currently duplicates `entry.tsx`. - No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed.
- `src/altScreen.tsx`, `components/commandPalette.tsx`, and `lib/slash.ts` exist, but are not part of the active runtime path from `entry.tsx` to `app.tsx`.

5457
ui-tui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,9 @@
"lint": "eslint src/", "lint": "eslint src/",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint src/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}'", "fmt": "prettier --write 'src/**/*.{ts,tsx}'",
"fix": "npm run lint:fix && npm run fmt" "fix": "npm run lint:fix && npm run fmt",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"ink": "^6.8.0", "ink": "^6.8.0",
@ -32,6 +34,7 @@
"globals": "^16", "globals": "^16",
"prettier": "^3", "prettier": "^3",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0" "typescript": "^5.7.0",
"vitest": "^4.1.3"
} }
} }

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js'
import { DEFAULT_THEME } from '../theme.js'
describe('constants', () => {
it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 }))
it('string arrays are populated', () => {
for (const arr of [FACES, PLACEHOLDERS, VERBS]) {
expect(arr.length).toBeGreaterThan(0)
arr.forEach(s => expect(typeof s).toBe('string'))
}
})
it('HOTKEYS are [key, desc] pairs', () => {
HOTKEYS.forEach(([k, d]) => {
expect(typeof k).toBe('string')
expect(typeof d).toBe('string')
})
})
it('TOOL_VERBS maps known tools', () => {
expect(TOOL_VERBS.terminal).toContain('terminal')
expect(TOOL_VERBS.read_file).toContain('reading')
})
it('INTERPOLATION_RE matches {!cmd}', () => {
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('{!date}')).toBe(true)
INTERPOLATION_RE.lastIndex = 0
expect(INTERPOLATION_RE.test('plain')).toBe(false)
})
it('ROLE produces glyph/body/prefix per role', () => {
for (const role of ['assistant', 'system', 'tool', 'user'] as const) {
expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph')
}
})
})

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { upsert } from '../lib/messages.js'
describe('upsert', () => {
it('appends when last role differs', () => {
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)
})
it('replaces when last role matches', () => {
expect(upsert([{ role: 'assistant', text: 'partial' }], 'assistant', 'full')[0]!.text).toBe('full')
})
it('appends to empty', () => {
expect(upsert([], 'user', 'first')).toEqual([{ role: 'user', text: 'first' }])
})
it('does not mutate', () => {
const prev = [{ role: 'user' as const, text: 'hi' }]
upsert(prev, 'assistant', 'yo')
expect(prev).toHaveLength(1)
})
})

View file

@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import {
compactPreview,
estimateRows,
fmtK,
hasAnsi,
hasInterpolation,
pick,
stripAnsi,
userDisplay
} from '../lib/text.js'
describe('stripAnsi / hasAnsi', () => {
it('strips ANSI codes', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red')
})
it('passes plain text through', () => {
expect(stripAnsi('hello')).toBe('hello')
})
it('detects ANSI', () => {
expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true)
expect(hasAnsi('plain')).toBe(false)
})
})
describe('compactPreview', () => {
it('truncates with ellipsis', () => {
expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20)
expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…')
})
it('returns short strings as-is', () => {
expect(compactPreview('hello', 20)).toBe('hello')
})
it('collapses whitespace', () => {
expect(compactPreview(' a b ', 20)).toBe('a b')
})
it('returns empty for whitespace-only', () => {
expect(compactPreview(' ', 20)).toBe('')
})
})
describe('estimateRows', () => {
it('single line', () => expect(estimateRows('hello', 80)).toBe(1))
it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2))
it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3))
it('skips table separators', () => {
expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2)
})
it('handles code blocks', () => {
expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2)
})
it('compact mode skips empty lines', () => {
expect(estimateRows('a\n\nb', 80, true)).toBe(2)
expect(estimateRows('a\n\nb', 80, false)).toBe(3)
})
})
describe('fmtK', () => {
it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k'))
it('keeps small numbers', () => expect(fmtK(42)).toBe('42'))
it('boundary', () => {
expect(fmtK(1000)).toBe('1.0k')
expect(fmtK(999)).toBe('999')
})
})
describe('hasInterpolation', () => {
it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true))
it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false))
})
describe('pick', () => {
it('returns element from array', () => {
expect([1, 2, 3]).toContain(pick([1, 2, 3]))
})
})
describe('userDisplay', () => {
it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello'))
it('truncates long messages', () => {
expect(userDisplay('word '.repeat(100))).toContain('[long message]')
})
})

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_THEME, fromSkin } from '../theme.js'
describe('DEFAULT_THEME', () => {
it('has brand defaults', () => {
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
expect(DEFAULT_THEME.brand.prompt).toBe('')
expect(DEFAULT_THEME.brand.tool).toBe('┊')
})
it('has color palette', () => {
expect(DEFAULT_THEME.color.gold).toBe('#FFD700')
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
})
})
describe('fromSkin', () => {
it('overrides banner colors', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
})
it('preserves unset colors', () => {
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber)
})
it('overrides branding', () => {
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
expect(brand.name).toBe('TestBot')
expect(brand.prompt).toBe('$')
})
it('defaults for empty skin', () => {
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
})
it('passes banner logo/hero', () => {
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
})
it('maps ui_ color keys + cascades to status', () => {
const { color } = fromSkin({ ui_ok: '#008000' }, {})
expect(color.ok).toBe('#008000')
expect(color.statusGood).toBe('#008000')
})
})