mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title
Additional TUI fixes discovered in the same audit: 1. /plan slash command was silently lost — process_command() queues the plan skill invocation onto _pending_input which nobody reads in the slash worker subprocess. Now intercepted in slash.exec and routed through command.dispatch with a new 'send' dispatch type. Same interception added for /retry, /queue, /steer as safety nets (these already have correct TUI-local handlers in core.ts, but the server-side guard prevents regressions if the local handler is bypassed). 2. Tool results were stripping ANSI escape codes — the messageLine component used stripAnsi() + plain <Text> for tool role messages, losing all color/styling from terminal, search_files, etc. Now uses <Ansi> component (already imported) when ANSI is detected. 3. Terminal tab title now shows model + busy status via useTerminalTitle hook from @hermes/ink (was never used). Users can identify Hermes tabs and see at a glance whether the agent is busy or ready. 4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch parser + createSlashHandler handler for commands that need to inject a message into the conversation (plan, queue fallback, steer fallback).
This commit is contained in:
parent
2da558ec36
commit
abc95338c2
10 changed files with 196 additions and 7 deletions
|
|
@ -258,6 +258,72 @@ def test_slash_exec_rejects_skill_commands(server):
|
|||
assert "skill command" in resp["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"])
|
||||
def test_slash_exec_rejects_pending_input_commands(server, cmd):
|
||||
"""slash.exec must reject commands that use _pending_input in the CLI."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid, "agent": None}
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "r1",
|
||||
"method": "slash.exec",
|
||||
"params": {"command": cmd, "session_id": sid},
|
||||
})
|
||||
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4018
|
||||
assert "pending-input command" in resp["error"]["message"]
|
||||
|
||||
|
||||
def test_command_dispatch_queue_sends_message(server):
|
||||
"""command.dispatch /queue returns {type: 'send', message: ...} for the TUI."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid}
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "r1",
|
||||
"method": "command.dispatch",
|
||||
"params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
result = resp["result"]
|
||||
assert result["type"] == "send"
|
||||
assert result["message"] == "tell me about quantum computing"
|
||||
|
||||
|
||||
def test_command_dispatch_queue_requires_arg(server):
|
||||
"""command.dispatch /queue without an argument returns an error."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid}
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "r2",
|
||||
"method": "command.dispatch",
|
||||
"params": {"name": "queue", "arg": "", "session_id": sid},
|
||||
})
|
||||
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4004
|
||||
|
||||
|
||||
def test_command_dispatch_steer_fallback_sends_message(server):
|
||||
"""command.dispatch /steer with no active agent falls back to send."""
|
||||
sid = "test-session"
|
||||
server._sessions[sid] = {"session_key": sid, "agent": None}
|
||||
|
||||
resp = server.handle_request({
|
||||
"id": "r3",
|
||||
"method": "command.dispatch",
|
||||
"params": {"name": "steer", "arg": "focus on testing", "session_id": sid},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
result = resp["result"]
|
||||
assert result["type"] == "send"
|
||||
assert result["message"] == "focus on testing"
|
||||
|
||||
|
||||
def test_command_dispatch_returns_skill_payload(server):
|
||||
"""command.dispatch returns structured skill payload for the TUI to send()."""
|
||||
sid = "test-session"
|
||||
|
|
|
|||
|
|
@ -2117,6 +2117,56 @@ def _(rid, params: dict) -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Commands that queue messages onto _pending_input in the CLI ───
|
||||
# In the TUI the slash worker subprocess has no reader for that queue,
|
||||
# so we handle them here and return a structured payload.
|
||||
|
||||
if name in ("queue", "q"):
|
||||
if not arg:
|
||||
return _err(rid, 4004, "usage: /queue <prompt>")
|
||||
return _ok(rid, {"type": "send", "message": arg})
|
||||
|
||||
if name == "retry":
|
||||
agent = session.get("agent") if session else None
|
||||
if agent and hasattr(agent, "conversation_history"):
|
||||
hist = agent.conversation_history or []
|
||||
for m in reversed(hist):
|
||||
if m.get("role") == "user":
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
|
||||
)
|
||||
if content:
|
||||
return _ok(rid, {"type": "send", "message": content})
|
||||
return _err(rid, 4018, "no previous user message to retry")
|
||||
return _err(rid, 4018, "no active session to retry")
|
||||
|
||||
if name == "steer":
|
||||
if not arg:
|
||||
return _err(rid, 4004, "usage: /steer <prompt>")
|
||||
agent = session.get("agent") if session else None
|
||||
if agent and hasattr(agent, "steer"):
|
||||
try:
|
||||
accepted = agent.steer(arg)
|
||||
if accepted:
|
||||
return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}"})
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: no active run, treat as next-turn message
|
||||
return _ok(rid, {"type": "send", "message": arg})
|
||||
|
||||
if name == "plan":
|
||||
try:
|
||||
from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path
|
||||
plan_path = build_plan_path(session.get("session_key", "") if session else "")
|
||||
msg = _bsim("/plan", f"{arg} {plan_path}".strip() if arg else plan_path,
|
||||
task_id=session.get("session_key", "") if session else "")
|
||||
if msg:
|
||||
return _ok(rid, {"type": "send", "message": msg})
|
||||
except Exception as e:
|
||||
return _err(rid, 5030, f"plan skill failed: {e}")
|
||||
|
||||
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
|
||||
|
||||
|
||||
|
|
@ -2338,9 +2388,23 @@ def _(rid, params: dict) -> dict:
|
|||
# _pending_input which nobody reads in the worker subprocess. Reject
|
||||
# here so the TUI falls through to command.dispatch which handles skills
|
||||
# correctly (builds the invocation message and returns it to the client).
|
||||
#
|
||||
# The same applies to /retry, /queue, /steer, and /plan — they all
|
||||
# put messages on _pending_input that the slash worker never reads.
|
||||
# (/browser connect/disconnect also uses _pending_input for context
|
||||
# notes, but the actual browser operations need the slash worker's
|
||||
# env-var side effects, so they stay in slash.exec — only the context
|
||||
# note to the model is lost, which is low-severity.)
|
||||
_PENDING_INPUT_COMMANDS = frozenset({"retry", "queue", "q", "steer", "plan"})
|
||||
_cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split()
|
||||
_cmd_base = _cmd_parts[0] if _cmd_parts else ""
|
||||
|
||||
if _cmd_base in _PENDING_INPUT_COMMANDS:
|
||||
return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}")
|
||||
|
||||
try:
|
||||
from agent.skill_commands import scan_skill_commands
|
||||
_cmd_key = f"/{cmd.split()[0]}" if not cmd.startswith("/") else f"/{cmd.lstrip('/').split()[0]}"
|
||||
_cmd_key = f"/{_cmd_base}"
|
||||
if _cmd_key in scan_skill_commands():
|
||||
return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}")
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
|||
import { asCommandDispatch } from '../lib/rpc.js'
|
||||
|
||||
describe('asCommandDispatch', () => {
|
||||
it('parses exec, alias, and skill', () => {
|
||||
it('parses exec, alias, skill, and send', () => {
|
||||
expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })
|
||||
expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })
|
||||
expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({
|
||||
|
|
@ -11,11 +11,17 @@ describe('asCommandDispatch', () => {
|
|||
name: 'x',
|
||||
message: 'do'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({
|
||||
type: 'send',
|
||||
message: 'hello world'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed payloads', () => {
|
||||
expect(asCommandDispatch(null)).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'alias' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'send' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -152,6 +152,36 @@ describe('createSlashHandler', () => {
|
|||
})
|
||||
expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)
|
||||
})
|
||||
|
||||
it('handles send-type dispatch for /plan command', async () => {
|
||||
const planMessage = 'Plan skill content loaded'
|
||||
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
gw: {
|
||||
getLogTail: vi.fn(() => ''),
|
||||
request: vi.fn((method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
return Promise.reject(new Error('pending-input command'))
|
||||
}
|
||||
|
||||
if (method === 'command.dispatch') {
|
||||
return Promise.resolve({ type: 'send', message: planMessage })
|
||||
}
|
||||
|
||||
return Promise.resolve({})
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(() => Promise.resolve({}))
|
||||
}
|
||||
})
|
||||
|
||||
const h = createSlashHandler(ctx)
|
||||
expect(h('/plan create a REST API')).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
|
||||
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`)
|
||||
}
|
||||
|
||||
if (d.type === 'send') {
|
||||
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)
|
||||
}
|
||||
})
|
||||
.catch(guardedErr)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink'
|
||||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) {
|
|||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
||||
|
||||
// ── Terminal tab title ─────────────────────────────────────────────
|
||||
// Show model name + status so users can identify the Hermes tab.
|
||||
const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
||||
const titleStatus = ui.busy ? '⏳' : '✓'
|
||||
const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'
|
||||
useTerminalTitle(terminalTitle)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid || !stdout) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -28,12 +28,18 @@ export const MessageLine = memo(function MessageLine({
|
|||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
|
||||
'(empty tool result)'
|
||||
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
|
||||
'(empty tool result)'}
|
||||
</Text>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Ansi>{compactPreview(msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}</Ansi>
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{preview}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type CommandDispatchResponse =
|
|||
| { output?: string; type: 'exec' | 'plugin' }
|
||||
| { target: string; type: 'alias' }
|
||||
| { message?: string; name: string; type: 'skill' }
|
||||
| { message: string; type: 'send' }
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
|
|||
return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }
|
||||
}
|
||||
|
||||
if (t === 'send' && typeof o.message === 'string') {
|
||||
return { type: 'send', message: o.message }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -93,6 +93,7 @@ declare module '@hermes/ink' {
|
|||
export function useHasSelection(): boolean
|
||||
export function useStdout(): { readonly stdout?: NodeJS.WriteStream }
|
||||
export function useTerminalFocus(): boolean
|
||||
export function useTerminalTitle(title: string | null): void
|
||||
export function useDeclaredCursor(args: {
|
||||
readonly line: number
|
||||
readonly column: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue