mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
fix(tui): persist model switches by default
This commit is contained in:
@@ -347,6 +347,30 @@ def test_complete_slash_includes_provider_alias():
|
||||
assert any(item["text"] == "provider" for item in resp["result"]["items"])
|
||||
|
||||
|
||||
def test_complete_slash_includes_tui_details_command():
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/det"}}
|
||||
)
|
||||
|
||||
assert any(item["text"] == "/details" for item in resp["result"]["items"])
|
||||
|
||||
|
||||
def test_complete_slash_details_args():
|
||||
resp_section = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/details t"}}
|
||||
)
|
||||
resp_mode = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "complete.slash",
|
||||
"params": {"text": "/details thinking e"},
|
||||
}
|
||||
)
|
||||
|
||||
assert any(item["text"] == "thinking" for item in resp_section["result"]["items"])
|
||||
assert any(item["text"] == "expanded" for item in resp_mode["result"]["items"])
|
||||
|
||||
|
||||
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(reasoning_config=None)
|
||||
|
||||
@@ -3710,6 +3710,65 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"items": items})
|
||||
|
||||
|
||||
def _details_completion_item(value: str, meta: str = "") -> dict:
|
||||
return {"text": value, "display": value, "meta": meta}
|
||||
|
||||
|
||||
def _details_completions(text: str) -> list[dict] | None:
|
||||
if not text.lower().startswith("/details"):
|
||||
return None
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped and not "/details".startswith(stripped.lower().split()[0]):
|
||||
return None
|
||||
|
||||
body = text[len("/details"):]
|
||||
if body.startswith(" "):
|
||||
body = body[1:]
|
||||
parts = body.split()
|
||||
has_trailing_space = text.endswith(" ")
|
||||
sections = ("thinking", "tools", "subagents", "activity")
|
||||
modes = ("hidden", "collapsed", "expanded")
|
||||
|
||||
if not body or (len(parts) == 0 and has_trailing_space):
|
||||
return [
|
||||
*[_details_completion_item(mode, "global mode") for mode in modes],
|
||||
_details_completion_item("cycle", "cycle global mode"),
|
||||
*[_details_completion_item(section, "section override") for section in sections],
|
||||
]
|
||||
|
||||
if len(parts) == 1 and not has_trailing_space:
|
||||
prefix = parts[0].lower()
|
||||
candidates = [*modes, "cycle", *sections]
|
||||
return [
|
||||
_details_completion_item(
|
||||
candidate,
|
||||
"section override" if candidate in sections else "global mode",
|
||||
)
|
||||
for candidate in candidates
|
||||
if candidate.startswith(prefix) and candidate != prefix
|
||||
]
|
||||
|
||||
if len(parts) == 1 and has_trailing_space and parts[0].lower() in sections:
|
||||
return [
|
||||
*[_details_completion_item(mode, f"set {parts[0].lower()}") for mode in modes],
|
||||
_details_completion_item("reset", f"clear {parts[0].lower()} override"),
|
||||
]
|
||||
|
||||
if len(parts) == 2 and not has_trailing_space and parts[0].lower() in sections:
|
||||
prefix = parts[1].lower()
|
||||
return [
|
||||
_details_completion_item(
|
||||
candidate,
|
||||
f"clear {parts[0].lower()} override" if candidate == "reset" else f"set {parts[0].lower()}",
|
||||
)
|
||||
for candidate in (*modes, "reset")
|
||||
if candidate.startswith(prefix) and candidate != prefix
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@method("complete.slash")
|
||||
def _(rid, params: dict) -> dict:
|
||||
text = params.get("text", "")
|
||||
@@ -3742,6 +3801,11 @@ def _(rid, params: dict) -> dict:
|
||||
"display": "/compact",
|
||||
"meta": "Toggle compact display mode",
|
||||
},
|
||||
{
|
||||
"text": "/details",
|
||||
"display": "/details",
|
||||
"meta": "Control agent detail visibility",
|
||||
},
|
||||
{
|
||||
"text": "/logs",
|
||||
"display": "/logs",
|
||||
@@ -3753,6 +3817,14 @@ def _(rid, params: dict) -> dict:
|
||||
item["text"] == extra["text"] for item in items
|
||||
):
|
||||
items.append(extra)
|
||||
|
||||
details_items = _details_completions(text)
|
||||
if details_items is not None:
|
||||
return _ok(
|
||||
rid,
|
||||
{"items": details_items, "replace_from": text.rfind(" ") + 1},
|
||||
)
|
||||
|
||||
return _ok(
|
||||
rid,
|
||||
{"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1},
|
||||
|
||||
@@ -25,6 +25,36 @@ describe('createSlashHandler', () => {
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists typed /model switches by default', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
...buildGateway(),
|
||||
rpc: vi.fn(() => Promise.resolve({ value: 'x-model' }))
|
||||
}
|
||||
})
|
||||
|
||||
expect(createSlashHandler(ctx)('/model x-model')).toBe(true)
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||
key: 'model',
|
||||
session_id: 'sid-abc',
|
||||
value: 'x-model --global'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not duplicate --global for explicit persistent model switches', () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/model x-model --global')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||
key: 'model',
|
||||
session_id: 'sid-abc',
|
||||
value: 'x-model --global'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the skills hub locally for bare /skills', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const GLOBAL_MODEL_FLAG_RE = /(?:^|\s)--global(?:\s|$)/
|
||||
|
||||
const persistedModelArg = (arg: string) => {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
return GLOBAL_MODEL_FLAG_RE.test(trimmed) ? trimmed : `${trimmed} --global`
|
||||
}
|
||||
|
||||
export const sessionCommands: SlashCommand[] = [
|
||||
{
|
||||
aliases: ['bg'],
|
||||
@@ -69,21 +77,23 @@ export const sessionCommands: SlashCommand[] = [
|
||||
return patchOverlayState({ modelPicker: true })
|
||||
}
|
||||
|
||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return ctx.transcript.sys('error: invalid response: model switch')
|
||||
}
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: persistedModelArg(arg) })
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return ctx.transcript.sys('error: invalid response: model switch')
|
||||
}
|
||||
|
||||
ctx.transcript.sys(`model → ${r.value}`)
|
||||
ctx.local.maybeWarn(r)
|
||||
ctx.transcript.sys(`model → ${r.value}`)
|
||||
ctx.local.maybeWarn(r)
|
||||
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
|
||||
}))
|
||||
})
|
||||
)
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} }
|
||||
}))
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -627,7 +627,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
const onModelSelect = useCallback((value: string) => {
|
||||
patchOverlayState({ modelPicker: false })
|
||||
slashRef.current(`/model ${value}`)
|
||||
slashRef.current(`/model ${value} --global`)
|
||||
}, [])
|
||||
|
||||
const hasReasoning = Boolean(turn.reasoning.trim())
|
||||
|
||||
Reference in New Issue
Block a user