mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): preserve slash command and host compression semantics
Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec. Propagate the full compression timeout through compute-host control, return structured host compression outcomes with metadata, and retain successful compression feedback in the desktop transcript. Add regressions for timeout forwarding, host aborts and metadata sync, structured host control responses, command routing parity, and numeric stop counts.
This commit is contained in:
parent
a209ac9936
commit
1ed7a0c0fb
10 changed files with 235 additions and 54 deletions
|
|
@ -427,7 +427,9 @@ describe('usePromptActions /compress', () => {
|
|||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
expect(renderedSeedTexts(seeds)).toEqual(expect.arrayContaining(['compute-host summary', 'compute-host answer']))
|
||||
const computeHostTexts = renderedSeedTexts(seeds)
|
||||
expect(computeHostTexts).toEqual(expect.arrayContaining(['compute-host summary', 'compute-host answer']))
|
||||
expect(computeHostTexts.some(text => text.includes('Compressed 4 → 2 messages'))).toBe(true)
|
||||
expect($notifications.get()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ message: 'Compressed 4 → 2 messages' })])
|
||||
)
|
||||
|
|
|
|||
|
|
@ -420,6 +420,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
)
|
||||
|
||||
const aborted = result.status === 'aborted' || result.summary.aborted === true
|
||||
|
||||
if (!aborted) {
|
||||
// Keep a durable record of a successful manual compression in
|
||||
// the chat after replacing it with the authoritative backend
|
||||
// transcript. Errors remain transient: appending an error as a
|
||||
// system message would look like a successful state change.
|
||||
renderSlashOutput(lines.join('\n'))
|
||||
}
|
||||
notify({ durationMs: 5_000, id: noticeId, kind: aborted ? 'error' : 'success', message: lines.join('\n') })
|
||||
|
||||
return
|
||||
|
|
@ -428,17 +436,20 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
|||
const hostOutput = result?.host_ack?.output?.trim()
|
||||
|
||||
if (hostOutput) {
|
||||
renderSlashOutput(hostOutput)
|
||||
notify({ durationMs: 5_000, id: noticeId, kind: 'success', message: hostOutput })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const removed = result?.removed ?? 0
|
||||
const message = removed > 0 ? `compressed ${removed} messages` : 'nothing to compress'
|
||||
renderSlashOutput(message)
|
||||
notify({
|
||||
durationMs: 5_000,
|
||||
id: noticeId,
|
||||
kind: 'success',
|
||||
message: removed > 0 ? `compressed ${removed} messages` : 'nothing to compress'
|
||||
message
|
||||
})
|
||||
} catch (err) {
|
||||
dismissNotification(noticeId)
|
||||
|
|
|
|||
|
|
@ -176,12 +176,12 @@ describe('renderRpcResult', () => {
|
|||
})
|
||||
|
||||
describe('process.stop', () => {
|
||||
it('reports killed processes positively', () => {
|
||||
expect(renderRpcResult({ killed: true }, 'stop')).toBe('Stopped all background processes.')
|
||||
it('reports the numeric number of stopped processes', () => {
|
||||
expect(renderRpcResult({ killed: 2 }, 'stop')).toBe('Stopped 2 background processes.')
|
||||
})
|
||||
|
||||
it('reports nothing-to-stop when killed is false', () => {
|
||||
expect(renderRpcResult({ killed: false }, 'stop')).toBe('No background processes to stop.')
|
||||
it('reports nothing-to-stop when the numeric count is zero', () => {
|
||||
expect(renderRpcResult({ killed: 0 }, 'stop')).toBe('No background processes to stop.')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -246,9 +246,9 @@ export function renderRpcResult(response: unknown, name: string): string {
|
|||
return 'Steer rejected — agent declined input'
|
||||
}
|
||||
|
||||
// process.stop — { killed: boolean }
|
||||
if ('killed' in r && typeof r.killed === 'boolean') {
|
||||
return r.killed ? 'Stopped all background processes.' : 'No background processes to stop.'
|
||||
// process.stop — { killed: number }
|
||||
if ('killed' in r && typeof r.killed === 'number') {
|
||||
return r.killed > 0 ? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.` : 'No background processes to stop.'
|
||||
}
|
||||
|
||||
// session.save — { file }
|
||||
|
|
|
|||
|
|
@ -95,19 +95,13 @@ describe('desktop slash command curation', () => {
|
|||
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes commands with dedicated gateway RPCs to the rpc surface', () => {
|
||||
const rpcNames = ['/agents', '/save', '/status', '/steer', '/stop', '/usage'] as const
|
||||
|
||||
it('routes only stateless session commands through dedicated gateway RPCs', () => {
|
||||
const expected = {
|
||||
'/agents': 'agents.list',
|
||||
'/save': 'session.save',
|
||||
'/status': 'session.status',
|
||||
'/steer': 'session.steer',
|
||||
'/stop': 'process.stop',
|
||||
'/usage': 'session.usage'
|
||||
'/status': 'session.status'
|
||||
} as const
|
||||
|
||||
for (const name of rpcNames) {
|
||||
for (const [name, rpcName] of Object.entries(expected)) {
|
||||
const surface = resolveDesktopCommand(name)?.surface
|
||||
expect(surface?.kind).toBe('rpc')
|
||||
|
||||
|
|
@ -115,22 +109,16 @@ describe('desktop slash command curation', () => {
|
|||
continue
|
||||
}
|
||||
|
||||
expect(surface.rpc).toBe(expected[name])
|
||||
expect(surface.rpc).toBe(rpcName)
|
||||
expect(surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })).toEqual({
|
||||
session_id: 's-1'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const params = surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })
|
||||
|
||||
// process.stop doesn't take a session_id — kills ALL background
|
||||
// processes. Other commands must echo the active session id.
|
||||
if (name === '/stop') {
|
||||
expect(params).not.toHaveProperty('session_id')
|
||||
} else {
|
||||
expect(params.session_id).toBe('s-1')
|
||||
}
|
||||
|
||||
// steer threads the typed arg through as `text`; others ignore it.
|
||||
if (name === '/steer') {
|
||||
expect(params.text).toBe('topic A')
|
||||
}
|
||||
it('keeps commands with richer CLI semantics on the slash worker', () => {
|
||||
for (const name of ['/agents', '/steer', '/stop', '/usage']) {
|
||||
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -285,7 +273,7 @@ describe('desktop slash command curation', () => {
|
|||
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
|
||||
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
|
||||
expect(resolveDesktopCommand('/usage')?.surface.kind).toBe('rpc')
|
||||
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
|
||||
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
|
||||
// Skill / quick commands aren't in the registry.
|
||||
expect(resolveDesktopCommand('/gif-search')).toBeNull()
|
||||
|
|
|
|||
|
|
@ -185,12 +185,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||
// route to it directly via `rpc(...)` — bypassing slash.exec avoids the
|
||||
// slash-worker pipe timeout and the "not a quick/plugin/skill command"
|
||||
// fallback noise for commands the dispatcher doesn't handle inline.
|
||||
{
|
||||
name: '/agents',
|
||||
description: 'Show active desktop sessions and running tasks',
|
||||
aliases: ['/tasks'],
|
||||
surface: rpc('agents.list', ctx => ({ session_id: ctx.sessionId }))
|
||||
},
|
||||
// These commands have gateway RPCs, but their established desktop behavior
|
||||
// carries richer CLI semantics: /agents includes delegations, /stop cancels
|
||||
// them, /steer falls back to a next-turn prompt, and /usage is a formatted
|
||||
// live report. Keep them on slash.exec until their RPC contracts are fully
|
||||
// equivalent.
|
||||
{ name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
|
||||
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
|
||||
// /compress must be an action (session.compress RPC), not exec: the slash
|
||||
// worker route times out on large sessions (30s WS / 45s pipe) before the
|
||||
|
|
@ -231,19 +231,11 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||
description: 'Show current session status',
|
||||
surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId }))
|
||||
},
|
||||
{
|
||||
name: '/steer',
|
||||
description: 'Steer the current run after the next tool call',
|
||||
surface: rpc('session.steer', ctx => ({ session_id: ctx.sessionId, text: ctx.arg.trim() }))
|
||||
},
|
||||
{ name: '/stop', description: 'Stop running background processes', surface: rpc('process.stop', () => ({})) },
|
||||
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec(), args: true },
|
||||
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
|
||||
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
|
||||
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
|
||||
{
|
||||
name: '/usage',
|
||||
description: 'Show token usage for this session',
|
||||
surface: rpc('session.usage', ctx => ({ session_id: ctx.sessionId }))
|
||||
},
|
||||
{ name: '/usage', description: 'Show token usage for this session', surface: exec() },
|
||||
{ name: '/version', description: 'Show Hermes Agent version', surface: exec() },
|
||||
|
||||
// No desktop surface, but carry an alias (underscore spelling variants).
|
||||
|
|
|
|||
|
|
@ -2527,7 +2527,7 @@ def test_make_agent_passes_configured_fallback_chain(monkeypatch):
|
|||
|
||||
assert agent.model == "gpt-5.5"
|
||||
assert captured["fallback_model"] == fallback_chain
|
||||
assert captured["platform"] == "tui"
|
||||
assert captured["platform"] == "desktop"
|
||||
|
||||
|
||||
def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch):
|
||||
|
|
@ -3811,7 +3811,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
|
|||
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
|
||||
|
||||
assert created == [
|
||||
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
|
||||
{"key": "k1", "source": "desktop", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -3851,7 +3851,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
|
|||
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
|
||||
|
||||
assert created == [
|
||||
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": None}
|
||||
{"key": "k1", "source": "desktop", "model": "test-model", "model_config": None, "cwd": None}
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -5908,6 +5908,88 @@ def test_session_compress_returns_compute_host_history(monkeypatch):
|
|||
}
|
||||
|
||||
|
||||
def test_session_compress_forwards_120_second_budget_to_compute_host(monkeypatch):
|
||||
session = _session(agent=None, _compute_host_active=True)
|
||||
server._sessions["sid"] = session
|
||||
calls = []
|
||||
|
||||
def send_control(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return {
|
||||
"type": "control.ack",
|
||||
"result": {
|
||||
"status": "compressed",
|
||||
"messages": [],
|
||||
"removed": 0,
|
||||
"summary": {"headline": "Already compressed", "noop": True},
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
|
||||
monkeypatch.setattr(server, "_send_compute_host_control", send_control)
|
||||
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert resp["result"]["status"] == "compressed"
|
||||
assert calls == [
|
||||
(
|
||||
("sid",),
|
||||
{
|
||||
"route_name": "session.compress",
|
||||
"command": "/compress",
|
||||
"wait": True,
|
||||
"timeout": 120.0,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_session_compress_preserves_compute_host_aborted_summary(monkeypatch):
|
||||
session = _session(agent=None, _compute_host_active=True)
|
||||
server._sessions["sid"] = session
|
||||
result = {
|
||||
"status": "aborted",
|
||||
"messages": [{"role": "user", "content": "preserved context"}],
|
||||
"removed": 0,
|
||||
"summary": {
|
||||
"aborted": True,
|
||||
"headline": "Compression aborted: 6 messages preserved",
|
||||
"note": "No compression provider is configured.",
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: True)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_send_compute_host_control",
|
||||
lambda *args, **kwargs: {
|
||||
"type": "control.ack",
|
||||
"result": result,
|
||||
"session_key": "rotated-host-key",
|
||||
"history_version": 7,
|
||||
"message_count": 1,
|
||||
"session_info": {"model": "host-model"},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert resp["result"] == {**result, "turn_isolation": True}
|
||||
assert session["session_key"] == "rotated-host-key"
|
||||
assert session["history_version"] == 7
|
||||
assert session["_metadata_message_count"] == 1
|
||||
assert session["_metadata_mirror"]["model"] == "host-model"
|
||||
|
||||
|
||||
def test_session_compress_reports_aborted_summary_without_success(monkeypatch):
|
||||
compression_state = types.SimpleNamespace(
|
||||
_last_compress_aborted=True,
|
||||
|
|
|
|||
|
|
@ -252,6 +252,62 @@ def test_compute_host_compress_control_runs_identity_guard_in_host(monkeypatch):
|
|||
assert ack["session_info"]["model"] == "host-model"
|
||||
|
||||
|
||||
def test_compute_host_session_compress_returns_structured_result(monkeypatch):
|
||||
from tui_gateway import server
|
||||
|
||||
out = io.StringIO()
|
||||
host = ComputeHost(stdout=out, max_workers=1, heartbeat_secs=0)
|
||||
session = {
|
||||
"agent": None,
|
||||
"session_key": "host-key",
|
||||
"history": [{"role": "user", "content": "preserved"}],
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 3,
|
||||
"running": False,
|
||||
}
|
||||
calls: list[dict] = []
|
||||
|
||||
def compress_handler(_rid, params):
|
||||
calls.append(params)
|
||||
return {
|
||||
"result": {
|
||||
"status": "aborted",
|
||||
"messages": [{"role": "user", "content": "preserved"}],
|
||||
"summary": {"aborted": True, "headline": "Compression aborted"},
|
||||
}
|
||||
}
|
||||
|
||||
server._sessions["sid"] = session
|
||||
monkeypatch.setitem(server._methods, "session.compress", compress_handler)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent, _session: {"model": "host-model"})
|
||||
|
||||
try:
|
||||
host.handle_frame(
|
||||
{
|
||||
"type": "control",
|
||||
"sid": "sid",
|
||||
"request_id": "compress-structured",
|
||||
"route_name": "session.compress",
|
||||
"command": "/compress auth",
|
||||
}
|
||||
)
|
||||
ack = _wait_for_frame(
|
||||
out,
|
||||
lambda frame: frame.get("type") == "control.ack" and frame.get("request_id") == "compress-structured",
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
host.close()
|
||||
|
||||
assert calls == [{"session_id": "sid", "focus_topic": "auth"}]
|
||||
assert ack["result"]["status"] == "aborted"
|
||||
assert ack["result"]["summary"]["aborted"] is True
|
||||
assert ack["session_key"] == "host-key"
|
||||
assert ack["history_version"] == 3
|
||||
assert ack["message_count"] == 1
|
||||
assert ack["session_info"] == {"model": "host-model"}
|
||||
|
||||
|
||||
def test_append_log_record_single_write_lines(tmp_path):
|
||||
path = tmp_path / "agent.log"
|
||||
|
||||
|
|
|
|||
|
|
@ -570,6 +570,44 @@ class ComputeHost:
|
|||
}
|
||||
)
|
||||
return
|
||||
if route_name == "session.compress":
|
||||
command = str(frame.get("command") or "")
|
||||
focus_topic = command.removeprefix("/compress").strip()
|
||||
response = server._methods["session.compress"](
|
||||
request_id,
|
||||
{
|
||||
"session_id": sid,
|
||||
**({"focus_topic": focus_topic} if focus_topic else {}),
|
||||
},
|
||||
)
|
||||
if "error" in response:
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.error",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"message": str(response["error"].get("message") or "session compression failed"),
|
||||
}
|
||||
)
|
||||
return
|
||||
with session["history_lock"]:
|
||||
session_key = str(session.get("session_key") or "")
|
||||
history_version = int(session.get("history_version", 0))
|
||||
message_count = len(session.get("history") or [])
|
||||
self.emit(
|
||||
{
|
||||
"type": "control.ack",
|
||||
"sid": sid,
|
||||
"request_id": request_id,
|
||||
"route_name": route_name,
|
||||
"result": response.get("result") or {},
|
||||
"session_key": session_key,
|
||||
"history_version": history_version,
|
||||
"message_count": message_count,
|
||||
"session_info": server._session_info(session.get("agent"), session),
|
||||
}
|
||||
)
|
||||
return
|
||||
command = str(frame.get("command") or "")
|
||||
output = ""
|
||||
if command:
|
||||
|
|
|
|||
|
|
@ -1381,6 +1381,7 @@ def _send_compute_host_control(
|
|||
command: str = "",
|
||||
payload: dict | None = None,
|
||||
wait: bool = True,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
frame = dict(payload or {})
|
||||
frame.setdefault("type", "control")
|
||||
|
|
@ -1390,6 +1391,7 @@ def _send_compute_host_control(
|
|||
route_name=route_name,
|
||||
payload=frame,
|
||||
wait=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -8991,6 +8993,7 @@ def _(rid, params: dict) -> dict:
|
|||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
assert session is not None
|
||||
if _session_uses_compute_host(session):
|
||||
sid = str(params.get("session_id") or "")
|
||||
focus_topic = str(params.get("focus_topic", "") or "").strip()
|
||||
|
|
@ -9001,12 +9004,21 @@ def _(rid, params: dict) -> dict:
|
|||
route_name="session.compress",
|
||||
command=command,
|
||||
wait=True,
|
||||
timeout=120.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _err(rid, 5019, f"compute-host compress failed: {exc}")
|
||||
if ack.get("type") in {"control.error", "error"}:
|
||||
return _err(rid, 4009, str(ack.get("message") or "compute-host compress failed"))
|
||||
_apply_compute_host_metadata_mirror(session, ack)
|
||||
host_result = ack.get("result")
|
||||
if isinstance(host_result, dict):
|
||||
# The host owns the isolated session's agent/history, so preserve
|
||||
# its structured compression result verbatim. In particular this
|
||||
# carries `status: aborted` and `summary.aborted`; flattening the
|
||||
# old text-only acknowledgement made Desktop show aborted work as a
|
||||
# success toast.
|
||||
return _ok(rid, {**host_result, "turn_isolation": True})
|
||||
host_info = ack.get("session_info") if isinstance(ack.get("session_info"), dict) else {}
|
||||
host_messages = ack.get("messages") if isinstance(ack.get("messages"), list) else []
|
||||
# `messages` is returned at top level for the desktop transcript
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue