mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add tests and update mds
This commit is contained in:
parent
f226e6be10
commit
9d8f9765c1
11 changed files with 6013 additions and 4 deletions
64
AGENTS.md
64
AGENTS.md
|
|
@ -56,6 +56,18 @@ hermes-agent/
|
|||
│ ├── run.py # Main loop, slash commands, message dispatch
|
||||
│ ├── session.py # SessionStore — conversation persistence
|
||||
│ └── 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)
|
||||
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||
├── 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
|
||||
|
||||
Requires changes in **3 files**:
|
||||
|
|
|
|||
0
tests/tui_gateway/__init__.py
Normal file
0
tests/tui_gateway/__init__.py
Normal file
187
tests/tui_gateway/test_protocol.py
Normal file
187
tests/tui_gateway/test_protocol.py
Normal 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
|
||||
67
tests/tui_gateway/test_render.py
Normal file
67
tests/tui_gateway/test_render.py
Normal 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
|
||||
|
|
@ -294,5 +294,4 @@ tui_gateway/
|
|||
|
||||
## Notes
|
||||
|
||||
- `src/main.tsx` currently duplicates `entry.tsx`.
|
||||
- `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`.
|
||||
- No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed.
|
||||
|
|
|
|||
5457
ui-tui/package-lock.json
generated
Normal file
5457
ui-tui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,9 @@
|
|||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"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": {
|
||||
"ink": "^6.8.0",
|
||||
|
|
@ -32,6 +34,7 @@
|
|||
"globals": "^16",
|
||||
"prettier": "^3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
ui-tui/src/__tests__/constants.test.ts
Normal file
43
ui-tui/src/__tests__/constants.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
25
ui-tui/src/__tests__/messages.test.ts
Normal file
25
ui-tui/src/__tests__/messages.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
112
ui-tui/src/__tests__/text.test.ts
Normal file
112
ui-tui/src/__tests__/text.test.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
52
ui-tui/src/__tests__/theme.test.ts
Normal file
52
ui-tui/src/__tests__/theme.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue