From dd39ec26942241a4e3912ca5109233e20816163e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:44:17 -0500 Subject: [PATCH 1/9] fix(gateway): project a /skill turn onto its invocation for every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slash-skill invocation is persisted expanded — activation note plus the entire skill body. _history_to_messages is the single display projection every surface reads, so that payload rendered as a chat bubble anywhere a session was resumed. Project it here onto the invocation the user typed, and tag skill/bundle dispatches with the same string so the live send matches. Rewind and regenerate replay from what the transcript shows, so re-expand the invocation server-side before running the turn: the replayed prompt is identical to the original and no client ever holds the body. --- agent/skill_commands.py | 8 ++- tests/test_tui_gateway_server.py | 87 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 68 +++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 294ca2b1754..3f1156a8592 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -97,7 +97,7 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: return None -def describe_skill_invocation(content: Any) -> Optional[str]: +def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]: """Render a slash-skill-expanded turn the way the user typed it. The expanded message embeds the whole skill body, so any surface that @@ -109,6 +109,10 @@ def describe_skill_invocation(content: Any) -> Optional[str]: Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare invocation, or ``None`` when *content* is not skill scaffolding (the caller should then summarize it as an ordinary message). + + *separator* joins the command and the instruction. Previews use the + default em dash; pass ``" "`` for the literal invocation the user typed, + which is what chat transcripts render. """ if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX): return None @@ -127,7 +131,7 @@ def describe_skill_invocation(content: Any) -> Optional[str]: instruction = instruction.split(SKILL_EXCERPT_JOINT)[0] instruction = " ".join(instruction.split()) if instruction: - return f"{label} — {instruction}" if name else instruction + return f"{label}{separator}{instruction}" if name else instruction return label if name else None diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7ba25acc55c..849c3397ed7 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1753,6 +1753,93 @@ def test_history_to_messages_drops_display_hidden_scaffolding(): assert all("api_content" not in m for m in projected) +def test_history_to_messages_projects_a_skill_turn_to_its_invocation(): + # A /skill invocation is persisted EXPANDED: the activation note plus the + # entire skill body. That payload is model-facing scaffolding -- this + # projection is the single display source every client reads, so it must + # hand back the invocation the user typed and never the body. Without it a + # chat bubble renders the whole skill as if the user had written it. + scaffolded = ( + '[IMPORTANT: The user has invoked the "work" skill, indicating they ' + "want you to follow its instructions. The full skill content is " + "loaded below.]\n\n" + "# /work\n\nSPIN UP A WORKTREE, never the primary checkout.\n\n" + "The user has provided the following instruction alongside the skill " + "invocation: fix the title leak" + ) + + history = [ + {"role": "user", "content": scaffolded}, + {"role": "assistant", "content": "on it"}, + ] + + assert server._history_to_messages(history) == [ + { + "role": "user", + "text": "/work fix the title leak", + "display_kind": "skill_invocation", + }, + {"role": "assistant", "text": "on it"}, + ] + + +def test_history_to_messages_projects_a_bare_skill_turn_to_the_command(): + scaffolded = ( + '[IMPORTANT: The user has invoked the "work" skill, indicating they ' + "want you to follow its instructions. The full skill content is " + "loaded below.]\n\n# /work\n\nSPIN UP A WORKTREE." + ) + + assert server._history_to_messages([{"role": "user", "content": scaffolded}]) == [ + {"role": "user", "text": "/work", "display_kind": "skill_invocation"} + ] + + +def test_expand_skill_invocation_for_replay_round_trips_the_projection( + tmp_path, monkeypatch +): + # Rewind/regenerate replays a turn from what the transcript SHOWS, and a + # skill turn shows its invocation. Re-running that verbatim would send the + # agent the literal "/work fix it" instead of the skill, so the server + # re-expands it — the exact inverse of _skill_scaffold_projection, with the + # body never leaving the server. + import agent.skill_commands as skill_commands + import agent.skill_utils as skill_utils + import tools.skills_tool as skills_tool + + skills_dir = tmp_path / "skills" + (skills_dir / "worktree-kickoff").mkdir(parents=True) + (skills_dir / "worktree-kickoff" / "SKILL.md").write_text( + "---\nname: worktree-kickoff\ndescription: Spin up a worktree\n---\n\n" + "# kickoff\n\nSPIN UP A WORKTREE, never the primary checkout.\n" + ) + monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir) + monkeypatch.setattr(skill_utils, "get_external_skills_dirs", lambda *a, **k: []) + monkeypatch.setattr(skill_commands, "_skill_commands", {}) + monkeypatch.setattr(skill_commands, "_skill_commands_platform", None) + skill_commands.scan_skill_commands() + + expanded = server._expand_skill_invocation_for_replay( + "/worktree-kickoff fix it", "task-1" + ) + + assert "SPIN UP A WORKTREE" in expanded + assert server._skill_scaffold_projection(expanded) == "/worktree-kickoff fix it" + + +def test_expand_skill_invocation_for_replay_leaves_ordinary_text_alone(monkeypatch): + import agent.skill_commands as skill_commands + import agent.skill_utils as skill_utils + + monkeypatch.setattr(skill_utils, "get_external_skills_dirs", lambda *a, **k: []) + monkeypatch.setattr(skill_commands, "_skill_commands", {}) + monkeypatch.setattr(skill_commands, "_skill_commands_platform", None) + + assert server._expand_skill_invocation_for_replay("just words", "t") == "just words" + # A core slash command is not a skill — nothing to expand. + assert server._expand_skill_invocation_for_replay("/status", "t") == "/status" + + def test_history_to_messages_keeps_real_user_bracket_text(): # Only role=user rows whose text OPENS with the [System: marker sentinel are # bookkeeping notices. A genuine user turn that merely mentions the token is diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 14228035c43..9e78b64e8e4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -33,6 +33,7 @@ from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value from tools.environments.local import hermes_subprocess_env from agent.replay_cleanup import sanitize_replay_history +from agent.skill_commands import describe_skill_invocation from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX from tui_gateway import git_probe from tui_gateway.turn_marker import ( @@ -6237,6 +6238,51 @@ def _is_display_hidden_marker(role: str | None, text: str) -> bool: return role == "user" and text.lstrip().startswith("[System:") +def _skill_scaffold_projection(content_text: str) -> str: + """Return the invocation a slash-skill-expanded turn came from, else "". + + A ``/skill`` invocation expands into a model-facing message that embeds the + whole skill body. That payload belongs to the agent — every UI renders the + invocation (``/work fix the leak``) instead, so no surface can leak the + body into a chat bubble. + """ + return describe_skill_invocation(content_text, separator=" ") or "" + + +def _expand_skill_invocation_for_replay(text: str, task_id: str) -> str: + """Re-expand a projected `/skill` invocation before re-running that turn. + + The inverse of :func:`_skill_scaffold_projection`. Because a skill turn is + displayed as its invocation, a rewind/regenerate hands us back + ``/work fix the leak`` rather than the body the agent originally saw — + re-running that verbatim would drop the skill. Re-expanding here keeps the + body server-side (no client ever holds it) and makes the replayed turn + identical to the original. + + Returns *text* unchanged when it isn't a resolvable skill invocation. + """ + head, _, arg = (text or "").strip().partition(" ") + if not head.startswith("/"): + return text + + try: + from agent.skill_commands import ( + build_skill_invocation_message, + resolve_skill_command_key, + ) + + cmd_key = resolve_skill_command_key(head.lstrip("/")) + if cmd_key is None: + return text + + return build_skill_invocation_message(cmd_key, arg.strip(), task_id=task_id) or text + except Exception: + # A skill that no longer resolves (renamed, disabled, external dir + # gone) must not break the rewind — replay the text as typed. + logger.debug("skill re-expansion failed for replay", exc_info=True) + return text + + def _history_to_messages(history: list[dict]) -> list[dict]: messages = [] tool_call_args = {} @@ -6297,6 +6343,14 @@ def _history_to_messages(history: list[dict]) -> list[dict]: if not content_text.strip() and not has_reasoning: continue msg = {"role": role, "text": content_text} + if role == "user": + invocation = _skill_scaffold_projection(content_text) + if invocation: + # Show the invocation, never the expanded skill body. The raw + # payload stays server-side: a rewind/regenerate re-sends the + # turn by ordinal, so no client needs it. + msg["text"] = invocation + msg["display_kind"] = "skill_invocation" if role == "assistant": for key in reasoning_keys: if key in m and m.get(key) is not None: @@ -10842,6 +10896,14 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err + if truncate_user_ordinal is not None and isinstance(text, str): + # A rewind/regenerate replays a turn from what the transcript shows. A + # skill turn shows its invocation, so re-expand it here — otherwise + # re-running `/work fix it` sends the agent nine literal characters + # instead of the skill it originally loaded. + text = _expand_skill_invocation_for_replay( + text, str(session.get("session_key") or "") + ) isolation_cfg = _load_dashboard_process_isolation_config() turn_isolation = _session_uses_compute_host(session, isolation_cfg) # Re-bind to the current client transport for this request. This keeps @@ -15450,6 +15512,9 @@ def _(rid, params: dict) -> dict: "type": "send", "message": msg, "notice": notice, + # UIs render this, never `message` — the expanded bundle body + # is model-facing scaffolding (see _skill_scaffold_projection). + "display": _skill_scaffold_projection(msg), }, ) @@ -15472,6 +15537,9 @@ def _(rid, params: dict) -> dict: "type": "skill", "message": msg, "name": cmds[key].get("name", name), + # UIs render this, never `message` — the expanded skill + # body is model-facing scaffolding. + "display": _skill_scaffold_projection(msg), }, ) except Exception: From b700f9f253229a54785bd3762585db50bb0e52c6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:44:23 -0500 Subject: [PATCH 2/9] feat(shared): read a skill invocation out of its expanded scaffolding The client-side twin of the gateway's projection, shared by the desktop and the TUI so a surface talking to an older gateway still renders the invocation rather than the whole skill body. --- apps/shared/package.json | 1 + apps/shared/src/index.ts | 1 + apps/shared/src/skill-scaffold.test.ts | 61 ++++++++++++++++++++ apps/shared/src/skill-scaffold.ts | 78 ++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 apps/shared/src/skill-scaffold.test.ts create mode 100644 apps/shared/src/skill-scaffold.ts diff --git a/apps/shared/package.json b/apps/shared/package.json index 2a2f46ef73d..e3482d8a466 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,6 +8,7 @@ "./billing": "./src/billing-types.ts", "./billing-policy": "./src/billing-policy.ts", "./charge-settlement": "./src/charge-settlement.ts", + "./skill-scaffold": "./src/skill-scaffold.ts", "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 21c40a716db..2339c97716f 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -44,6 +44,7 @@ export { JsonRpcGatewayClient, type WebSocketLike } from './json-rpc-gateway' +export { dispatchDisplayText, skillInvocationText } from './skill-scaffold' export { type HermesSkin, SKIN_BRANDING_TOKENS, diff --git a/apps/shared/src/skill-scaffold.test.ts b/apps/shared/src/skill-scaffold.test.ts new file mode 100644 index 00000000000..954ff41a6c9 --- /dev/null +++ b/apps/shared/src/skill-scaffold.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { skillInvocationText } from './skill-scaffold' + +// Byte-identical to what agent/skill_commands.py emits — a desktop/TUI talking +// to an older gateway sees exactly these strings. +const BODY = 'SPIN UP A WORKTREE. Never edit the primary checkout.\n'.repeat(20) + +const singleSkill = (instruction?: string) => + [ + '[IMPORTANT: The user has invoked the "work" skill, indicating they want you to follow its instructions.', + 'The full skill content is loaded below.]', + '', + BODY, + '', + '[Skill directory: /Users/x/skills/work]', + ...(instruction + ? ['', `The user has provided the following instruction alongside the skill invocation: ${instruction}`] + : []) + ].join('\n') + +const bundle = (instruction?: string) => + [ + '[IMPORTANT: The user has invoked the "/clean /work" stacked skill bundle, loading 2 skills together.]', + '', + 'Skills loaded: clean, work', + ...(instruction ? ['', `User instruction: ${instruction}`] : []), + '', + '[Loaded as part of the stacked skill invocation "clean".]', + '', + BODY + ].join('\n') + +describe('skillInvocationText', () => { + it('renders a single-skill turn as the invocation, never the body', () => { + const projected = skillInvocationText(singleSkill('fix the title leak')) + + expect(projected).toBe('/work fix the title leak') + expect(projected).not.toContain('WORKTREE') + }) + + it('renders a bare invocation as just the command', () => { + expect(skillInvocationText(singleSkill())).toBe('/work') + }) + + it('renders a bundle turn as the typed keys plus the instruction', () => { + const projected = skillInvocationText(bundle('ship it')) + + expect(projected).toBe('/clean /work ship it') + expect(projected).not.toContain('WORKTREE') + }) + + it('collapses newlines in a multi-line instruction so the bubble stays one line', () => { + expect(skillInvocationText(singleSkill('fix the leak\n\nthen ship'))).toBe('/work fix the leak then ship') + }) + + it('leaves ordinary user prose alone', () => { + expect(skillInvocationText('just a normal message')).toBeNull() + expect(skillInvocationText('[IMPORTANT: read the docs]')).toBeNull() + }) +}) diff --git a/apps/shared/src/skill-scaffold.ts b/apps/shared/src/skill-scaffold.ts new file mode 100644 index 00000000000..a06ce3d567a --- /dev/null +++ b/apps/shared/src/skill-scaffold.ts @@ -0,0 +1,78 @@ +/** + * A `/skill` invocation expands into a model-facing message that embeds the + * whole skill body. That payload is for the agent — the UI shows the + * invocation the user typed (`/work fix the leak`) and nothing else. + * + * The gateway already projects this (see `_skill_scaffold_projection` in + * tui_gateway/server.py) and ships the result as `display` on a dispatch and + * as the `text` of a `skill_invocation` history row. This module is the + * client-side twin so a desktop/TUI talking to an older gateway — or any + * future path that hands raw scaffolding to a bubble — still renders the + * invocation instead of the body. + * + * The markers below mirror `agent/skill_commands.py` byte for byte. + */ + +const INVOCATION_PREFIX = '[IMPORTANT: The user has invoked the ' +const SINGLE_MARKER = 'The full skill content is loaded below.]' +const SINGLE_INSTRUCTION = 'The user has provided the following instruction alongside the skill invocation: ' +const RUNTIME_NOTE = '\n\n[Runtime note:' +const BUNDLE_MARKER = ' skill bundle,' +const BUNDLE_INSTRUCTION = '\nUser instruction: ' +const BUNDLE_SKILL_BLOCK = '\n\n[Loaded as part of the ' + +// The skill name is the first quoted span of the activation note, for both the +// single-skill (`work`) and the bundle (`/clean /work`) header. +const NAME_RE = new RegExp(`^${INVOCATION_PREFIX.replace(/[[\]]/g, '\\$&')}"([^"]*)"`) + +/** Text between `marker` and `end`, or '' when the marker is absent. */ +function between(text: string, marker: string, end: string, fromEnd = false): string { + const index = fromEnd ? text.lastIndexOf(marker) : text.indexOf(marker) + + if (index < 0) { + return '' + } + + const tail = text.slice(index + marker.length) + const stop = tail.indexOf(end) + + return (stop >= 0 ? tail.slice(0, stop) : tail).trim() +} + +/** + * The invocation a scaffolded turn came from (`/work fix the leak`), or null + * when `text` is ordinary user prose that should render as written. + */ +export function skillInvocationText(text: string): null | string { + if (!text.startsWith(INVOCATION_PREFIX)) { + return null + } + + const name = (NAME_RE.exec(text)?.[1] ?? '').trim() + + if (!name) { + return null + } + + // Bundle headers already carry their typed "/a /b" keys; a single skill is + // a bare name. The single-skill instruction trails the body (which may quote + // the marker), so match it from the end. + const label = name.startsWith('/') ? name : `/${name}` + + const instruction = text.includes(BUNDLE_MARKER) + ? between(text, BUNDLE_INSTRUCTION, BUNDLE_SKILL_BLOCK) + : text.includes(SINGLE_MARKER) + ? between(text, SINGLE_INSTRUCTION, RUNTIME_NOTE, true) + : '' + + return instruction ? `${label} ${instruction.replace(/\s+/g, ' ')}` : label +} + +/** + * What a skill/bundle dispatch should render as. Prefers the gateway's own + * projection; falls back to reading the payload for an older gateway that + * doesn't send one. Undefined for an ordinary send, which renders as written. + */ +export function dispatchDisplayText(display: string | undefined, message: string): string | undefined { + return display?.trim() || skillInvocationText(message) || undefined +} From 5a940180b492c6cae93cea35ba2c47cb28eb7d86 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:44:31 -0500 Subject: [PATCH 3/9] fix(tui): show the invocation for a skill send, not the skill body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry a display string through the submit path so the transcript renders what the user typed while the agent still receives the expanded skill. Drops the '⚡ loading skill' line — the invocation bubble says it. --- .../src/__tests__/createSlashHandler.test.ts | 13 +++++++++---- ui-tui/src/app/createSlashHandler.ts | 19 +++++++++++++++---- ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/submissionCore.ts | 13 +++++++++++-- ui-tui/src/app/useSubmission.ts | 5 +++-- ui-tui/src/gatewayTypes.ts | 4 ++-- ui-tui/src/lib/rpc.ts | 7 +++++-- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index d00646ac900..b5949362add 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -810,8 +810,10 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) - it('falls through to command.dispatch for skill commands and sends the message', async () => { - const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step' + it('falls through to command.dispatch for skill commands, sending the body but showing the invocation', async () => { + const skillMessage = + '[IMPORTANT: The user has invoked the "hermes-agent-dev" skill, indicating they want you to follow its instructions.\n' + + 'The full skill content is loaded below.]\n\nUse this skill to do X.\n\n## Steps\n1. First step' const ctx = buildCtx({ gateway: { @@ -836,9 +838,12 @@ describe('createSlashHandler', () => { const h = createSlashHandler(ctx) expect(h('/hermes-agent-dev')).toBe(true) await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev') + expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage, true, '/hermes-agent-dev') }) - expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) + // The expanded skill body is model-facing: no transcript line may carry it. + for (const [line] of ctx.transcript.sys.mock.calls) { + expect(line).not.toContain('Use this skill to do X') + } }) it('handles command.dispatch payloads returned directly by slash.exec', async () => { diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index dc798e842c6..9a50f77b617 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,3 +1,5 @@ +import { dispatchDisplayText } from '@hermes/shared/skill-scaffold' + import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' @@ -104,10 +106,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return void handler(`/${d.target}${argTail}`) } - if (d.type === 'skill') { - sys(`⚡ loading skill: ${d.name}`) + // A skill/bundle dispatch's `message` is the expanded skill body — + // model-facing scaffolding. The transcript shows the invocation instead; + // an ordinary send has no projection and goes through unchanged. + const sendDispatch = (display: string | undefined, message: string) => { + const shown = dispatchDisplayText(display, message) - return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) + return shown ? send(message, true, shown) : send(message) + } + + if (d.type === 'skill') { + return d.message?.trim() + ? sendDispatch(d.display, d.message) + : sys(`/${parsed.name}: skill payload missing message`) } if (d.type === 'send') { @@ -115,7 +126,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sys(d.notice) } - return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + return d.message?.trim() ? sendDispatch(d.display, d.message) : sys(`/${parsed.name}: empty message`) } if (d.type === 'prefill') { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d82fa1a90ed..1a446f69e15 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -526,7 +526,7 @@ export interface SlashHandlerContext { transcript: { page: (text: string, title?: string) => void panel: (title: string, sections: PanelSection[]) => void - send: (text: string) => void + send: (text: string, showUserMessage?: boolean, displayText?: string) => void setHistoryItems: StateSetter sys: (text: string) => void trimLastExchange: (items: Msg[]) => Msg[] diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 534ef6c8f03..98b416c44d9 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -42,7 +42,16 @@ export function markSubmitting(): void { // Submit a ready prompt (already resolved to be neither a slash command nor a // shell escape, with a live session). Pulled out of useSubmission so the // synchronous-busy invariant above is unit-testable without React test infra. -export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessage = true): void { +// +// `displayOverride` is what the transcript shows when it differs from what the +// agent receives — a `/skill` invocation expands into the whole skill body, and +// that scaffolding is model-facing only. +export function submitPrompt( + text: string, + deps: SubmitPromptDeps, + showUserMessage = true, + displayOverride?: string +): void { const sid = getUiState().sid if (!sid) { @@ -63,7 +72,7 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa deps.setLastUserMsg(text) if (show) { - deps.appendMessage({ role: 'user', text: displayText }) + deps.appendMessage({ role: 'user', text: displayOverride || displayText }) } patchUiState({ busy: true, status: 'running…' }) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index a70b1fd7390..0ced5f0b8a2 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -67,7 +67,7 @@ export function useSubmission(opts: UseSubmissionOptions) { }, [composerState.input, composerState.inputBuf]) const send = useCallback( - (text: string, showUserMessage = true) => { + (text: string, showUserMessage = true, displayText?: string) => { const expand = expandSnips(composerState.pasteSnips) submitPrompt( @@ -80,7 +80,8 @@ export function useSubmission(opts: UseSubmissionOptions) { setLastUserMsg, sys }, - showUserMessage + showUserMessage, + displayText ) }, [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 63219a1ab71..41c5063295e 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -70,8 +70,8 @@ export type { export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } - | { message?: string; name: string; type: 'skill' } - | { message: string; notice?: string; type: 'send' } + | { display?: string; message?: string; name: string; type: 'skill' } + | { display?: string; message: string; notice?: string; type: 'send' } | { message: string; notice?: string; type: 'prefill' } // ── Config ─────────────────────────────────────────────────────────── diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index fda9694ddeb..f54fb166f77 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -22,15 +22,18 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul return { type: 'alias', target: o.target } } + const str = (value: unknown) => (typeof value === 'string' ? value : undefined) + if (t === 'skill' && typeof o.name === 'string') { - return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } + return { type: 'skill', name: o.name, message: str(o.message), display: str(o.display) } } if (t === 'send' && typeof o.message === 'string') { return { type: 'send', message: o.message, - notice: typeof o.notice === 'string' ? o.notice : undefined + notice: str(o.notice), + display: str(o.display) } } From 20b2022b8c6bd4589100fd778a93592deb7be68b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:44:31 -0500 Subject: [PATCH 4/9] fix(desktop): render a skill send as its invocation everywhere it surfaces The bubble, the queue panel, and the queue editor all showed a queued or sent /skill turn's expanded body. Thread the invocation through submit and the queue entry, and chip a leading slash command the way a mid-prose one already chips. --- .../chat/composer/hooks/use-composer-queue.ts | 7 ++- .../src/app/chat/composer/queue-panel.tsx | 2 +- .../hooks/use-prompt-actions/index.test.tsx | 56 ++++++++++++++++++- .../session/hooks/use-prompt-actions/slash.ts | 22 ++++---- .../hooks/use-prompt-actions/submit.ts | 10 +++- .../session/hooks/use-prompt-actions/utils.ts | 5 ++ apps/desktop/src/app/types.ts | 5 ++ .../assistant-ui/directive-text.tsx | 11 ++-- apps/desktop/src/lib/chat-messages.ts | 11 +++- apps/desktop/src/lib/chat-runtime.ts | 8 ++- apps/desktop/src/store/composer-queue.ts | 14 ++++- 11 files changed, 123 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index dff3804bb69..e1bb5442fef 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -106,7 +106,9 @@ export function useComposerQueue({ entryId: entry.id, sessionKey: activeQueueSessionKey }) - loadIntoComposer(entry.text, entry.attachments) + // Edit what the panel SHOWS. A queued `/skill` entry's text is the + // expanded skill body — never drop that into the composer. + loadIntoComposer(entry.displayText ?? entry.text, entry.attachments) triggerHaptic('selection') focusInput() } @@ -135,7 +137,7 @@ export function useComposerQueue({ if (next) { setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) + loadIntoComposer(next.displayText ?? next.text, next.attachments) } else { setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) @@ -213,6 +215,7 @@ export function useComposerQueue({ const accepted = await Promise.resolve( onSubmit(entry.text, { attachments: entry.attachments, + ...(entry.displayText ? { displayText: entry.displayText } : {}), fromQueue: true, sessionId: drainRuntimeSessionId, storedSessionId: drainQueueSessionKey diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 591eeb10ebb..2f5612efd13 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -22,7 +22,7 @@ interface QueuePanelProps { } const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => - entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) + (entry.displayText ?? entry.text).trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) export function QueuePanel({ busy, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index c9cd19c6406..6bf51c1e0fc 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1215,9 +1215,9 @@ describe('usePromptActions slash.exec dispatch payloads', () => { it("sends a skill's kickoff into the TAB that invoked it, not the foreground chat", async () => { // `/work` in a fresh ⌘T tab: slash.exec returns a skill dispatch whose // `message` is the kickoff prompt. The dispatcher resolved the tab as its - // target, printed "⚡ loading skill" there — then submitted the kickoff - // with no target at all, so submit re-resolved from activeSessionIdRef and - // fired it as a user message into whatever conversation was on screen. + // target, then submitted the kickoff with no target at all, so submit + // re-resolved from activeSessionIdRef and fired it as a user message into + // whatever conversation was on screen. const tabRuntimeId = 'tab-runtime' const tabStoredId = 'tab-stored' @@ -1262,6 +1262,56 @@ describe('usePromptActions slash.exec dispatch payloads', () => { $queuedPromptsBySession.set({}) }) + it('renders a skill turn as its invocation — the expanded body never reaches a bubble', async () => { + // A `/skill` dispatch's `message` is the whole skill body (model-facing + // scaffolding). The agent must receive it verbatim; every UI surface — + // the user bubble and any system line — must show only `/work fix it`. + const skillBody = + '[IMPORTANT: The user has invoked the "work" skill, indicating they want you to follow its instructions.\n' + + 'The full skill content is loaded below.]\n\nSPIN UP A WORKTREE, never the primary checkout.\n\n' + + 'The user has provided the following instruction alongside the skill invocation: fix it' + + const states: Record[] = [] + const submitted: (Record | undefined)[] = [] + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'prompt.submit') { + submitted.push(params) + } + + return ( + method === 'slash.exec' ? { type: 'skill', name: 'work', message: skillBody, display: '/work fix it' } : {} + ) as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => states.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + await handle!.submitText('/work fix it') + + // The agent still gets the full skill. + expect(submitted).toEqual([expect.objectContaining({ text: skillBody })]) + + const rendered = states.flatMap(state => { + const messages = Array.isArray(state.messages) + ? (state.messages as Array<{ parts?: Array<{ text?: string }> }>) + : [] + + return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? '')) + }) + + expect(rendered).toContain('/work fix it') + expect(rendered.join('\n')).not.toContain('SPIN UP A WORKTREE') + expect(rendered.join('\n')).not.toContain('IMPORTANT: The user has invoked') + }) + it('slash status header carries the command token, not the full invocation', async () => { // `/goal ` used to echo the entire invocation in the mono // header AND the goal text again in the backend notice right under it. diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index c70e6977c0a..5bfccf1be7a 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -1,5 +1,7 @@ import { type MutableRefObject, useCallback, useRef } from 'react' +import { dispatchDisplayText } from '@hermes/shared' + import { getProfiles } from '@/hermes' import type { Translations } from '@/i18n' import { type ChatMessage, toChatMessages } from '@/lib/chat-messages' @@ -264,9 +266,10 @@ export function useSlashCommand(deps: SlashCommandDeps) { return } - if (dispatch.type === 'skill') { - renderSlashOutput(`⚡ loading skill: ${dispatch.name}`) - } + // A skill/bundle dispatch's `message` is the expanded skill body — + // model-facing scaffolding. Never render it; the bubble shows the + // invocation instead. + const displayText = dispatchDisplayText('display' in dispatch ? dispatch.display : undefined, message) // Gate on the TARGET session's own busy state, not the foreground // view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat @@ -286,7 +289,7 @@ export function useSlashCommand(deps: SlashCommandDeps) { // whichever chat is now in front. const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId - if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message })) { + if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message, displayText })) { renderSlashOutput('session busy — message queued to send when the current turn finishes') } else { renderSlashOutput('session busy — /interrupt the current turn before sending this command') @@ -299,12 +302,11 @@ export function useSlashCommand(deps: SlashCommandDeps) { // same pair the output writer and the busy gate above already use. // Bare `submitPromptText(message)` let submit re-resolve from // `activeSessionIdRef`, which names the FOREGROUND chat: a `/work` - // typed into a fresh ⌘T tab loaded the skill in that tab, printed - // "⚡ loading skill" there, then fired its kickoff as a user message - // into whatever conversation was on screen. Every other target the - // dispatcher serves (tile, background queue drain, a session created - // by this very call) had the same leak. - await submitPromptText(message, { sessionId, storedSessionId }) + // typed into a fresh ⌘T tab loaded the skill in that tab, then fired + // its kickoff as a user message into whatever conversation was on + // screen. Every other target the dispatcher serves (tile, background + // queue drain, a session created by this very call) had the same leak. + await submitPromptText(message, { sessionId, storedSessionId, displayText }) } try { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index d8988a0c926..33b6ab4c33e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -268,10 +268,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + // What the bubble shows. A `/skill` send carries the whole expanded + // skill body as its text — model-facing scaffolding — so the dispatcher + // hands us the invocation to render instead. Everything else shows what + // was typed. + const bubbleText = options?.displayText ?? visibleText + const buildUserMessage = (): ChatMessage => ({ id: optimisticId, role: 'user', - parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], + parts: [textPart(bubbleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))], attachmentRefs }) @@ -466,7 +472,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { if (!sessionId) { try { - sessionId = await createBackendSessionForSend(visibleText) + sessionId = await createBackendSessionForSend(bubbleText) } catch (err) { dropOptimistic(null) releaseBusy() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index b193b1aebf1..a2ec14eb2a5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -393,6 +393,11 @@ export interface SubmitTextOptions { * (queue drain, steer, external submit requests): the check is a no-op * without it. */ composerScope?: string | null + /** What the transcript shows for this send, when it differs from the text + * the agent receives. A `/skill` invocation expands into the whole skill + * body — model-facing scaffolding the UI must never render — so the slash + * dispatcher passes the invocation (`/work fix the leak`) here. */ + displayText?: string fromQueue?: boolean /** Runtime session id to submit into. Queue drains pass this so a * backgrounded/source session cannot be replaced by the current foreground diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 01ada56e935..f7452607f7c 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -132,12 +132,17 @@ export interface SkillCommandDispatchResponse { type: 'skill' name: string message?: string + /** The invocation the UI renders (`/work fix the leak`). `message` is the + * expanded skill body — model-facing scaffolding no surface may show. */ + display?: string } export interface SendCommandDispatchResponse { type: 'send' message: string notice?: string + /** Set for a skill-bundle send: see SkillCommandDispatchResponse.display. */ + display?: string } export interface PrefillCommandDispatchResponse { diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 94edc90963d..f04340c5d6e 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -177,16 +177,17 @@ const HERMES_DIRECTIVE_RE = new RegExp( 'g' ) -// A skill referenced mid-prose (`clean this up with /clean`). The composer -// inserts it as a pill, so the sent message renders it as one too rather than -// flattening back to raw text. Only matches after whitespace — a leading `/` -// is a command invocation, which never reaches a rendered message as text. +// A skill referenced in a sent message — either the invocation that opens it +// (`/work fix the leak`, which is all a skill turn ever renders as) or one +// named mid-prose (`clean this up with /clean`). The composer inserts both as +// pills, so the sent message renders them as pills too rather than flattening +// back to raw text. // // Unlike the composer's caret-anchored trigger, this scans finished text, so // it must reject a token that continues into a path: `/usr/local/bin` would // otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at // something other than another slash. -const SLASH_SKILL_RE = /(?<=\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g +const SLASH_SKILL_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/ diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 13cca5b511b..6ee82ebbb6e 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -1,5 +1,5 @@ import type { ThreadMessageLike } from '@assistant-ui/react' -import type { BillingBlock } from '@hermes/shared' +import { type BillingBlock, skillInvocationText } from '@hermes/shared' import { extractImageRefs } from '@/lib/embedded-images' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' @@ -301,6 +301,15 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown return textContent } + // A `/skill` turn is stored expanded (the whole skill body). Current + // gateways project it to the invocation before it ever reaches us; this is + // the fallback for an older backend that still ships the raw payload. + const invocation = skillInvocationText(textContent) + + if (invocation) { + return invocation + } + const marker = textContent.match(ATTACHED_CONTEXT_MARKER_RE) if (!marker || marker.index === undefined) { diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index b392d2692a3..65965c41b67 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -283,10 +283,14 @@ export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | nu return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null case 'skill': - return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null + return typeof row.name === 'string' + ? { type: 'skill', name: row.name, message: str(row.message), display: str(row.display) } + : null case 'send': - return typeof row.message === 'string' ? { type: 'send', message: row.message, notice: str(row.notice) } : null + return typeof row.message === 'string' + ? { type: 'send', message: row.message, notice: str(row.notice), display: str(row.display) } + : null case 'prefill': return typeof row.message === 'string' ? { type: 'prefill', message: row.message, notice: str(row.notice) } : null diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts index 9a048c78699..ba610b36275 100644 --- a/apps/desktop/src/store/composer-queue.ts +++ b/apps/desktop/src/store/composer-queue.ts @@ -5,6 +5,10 @@ import type { ComposerAttachment } from './composer' export interface QueuedPromptEntry { id: string text: string + /** What the queue panel and the sent bubble show, when it differs from the + * text the agent receives. A queued `/skill` invocation carries the whole + * expanded skill body as `text` — the UI shows the invocation instead. */ + displayText?: string attachments: ComposerAttachment[] queuedAt: number } @@ -110,7 +114,7 @@ export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEn export const enqueueQueuedPrompt = ( key: string | null | undefined, - payload: { text: string; attachments: ComposerAttachment[] } + payload: { text: string; attachments: ComposerAttachment[]; displayText?: string } ): null | QueuedPromptEntry => { const sid = sidOf(key) @@ -121,6 +125,7 @@ export const enqueueQueuedPrompt = ( const entry: QueuedPromptEntry = { id: nextId(), text: payload.text, + ...(payload.displayText ? { displayText: payload.displayText } : {}), attachments: cloneAttachments(payload.attachments), queuedAt: Date.now() } @@ -218,7 +223,12 @@ export const updateQueuedPrompt = ( changed = true - return { ...entry, text: update.text, attachments } + // The user rewrote the text, so any display projection it carried (a + // `/skill` invocation standing in for the expanded body) no longer + // describes it — what they typed is now what sends. + const { displayText: _dropped, ...rest } = entry + + return { ...rest, text: update.text, attachments } }) if (!changed) { From ddd6b57938af594246cf7fe0303a7eb55a228c8d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:47:49 -0500 Subject: [PATCH 5/9] test(desktop): a leading slash now chips, superseding #71664's exclusion #71664 asserted a leading slash never chips, correctly: a command only ever executed, so it never reached a rendered message as text. Projecting a skill turn back onto its invocation removes that precondition. --- .../src/components/assistant-ui/directive-text.test.ts | 10 ++++++++-- .../src/components/assistant-ui/directive-text.tsx | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.test.ts b/apps/desktop/src/components/assistant-ui/directive-text.test.ts index d5f7074e5d1..c177e2f5066 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/directive-text.test.ts @@ -74,8 +74,14 @@ describe('inline skill references', () => { expect(skills('roughly 3 /4 of it')).toEqual([]) }) - it('does not chip a leading slash — that is a command invocation, not prose', () => { - expect(skills('/clean')).toEqual([]) + it('chips a leading slash, which now reaches the transcript as a skill invocation', () => { + // #71664 asserted the opposite, and was right at the time: a leading slash + // only ever EXECUTED, so it never reached a rendered message as text — + // the turn that reached the bubble was the expanded skill body. Projecting + // a skill turn back onto `/work fix it` changes that precondition, so the + // invocation now has to chip like any other skill reference. + expect(skills('/clean')).toEqual(['/clean']) + expect(skills('/work fix the leak')).toEqual(['/work']) }) it('parses a skill chip alongside an @ reference', () => { diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index f04340c5d6e..418f59eda71 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -183,6 +183,11 @@ const HERMES_DIRECTIVE_RE = new RegExp( // pills, so the sent message renders them as pills too rather than flattening // back to raw text. // +// #71664 deliberately excluded a LEADING slash, and was right then: a command +// only ever executed, so it never reached a rendered message as text. Skill +// turns now project back onto their invocation, so that precondition is gone +// and `^` joins the lookbehind. +// // Unlike the composer's caret-anchored trigger, this scans finished text, so // it must reject a token that continues into a path: `/usr/local/bin` would // otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at From e682a9c0a749fc85e772115abf42b9bfa6a585ec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 03:54:49 -0500 Subject: [PATCH 6/9] refactor: drop the shared subpath export, keeping package.json untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subpath entry existed only so the TUI could import the client-side projection fallback. The TUI spawns its gateway from this same checkout and cannot version-skew with it, so that fallback was dead weight — it reads `display` directly now. With its last caller gone the dispatch helper folds back into the desktop, where an older backend is genuinely reachable. apps/shared/package.json is byte-identical to main again. --- .../src/app/session/hooks/use-prompt-actions/slash.ts | 8 +++++--- apps/shared/package.json | 1 - apps/shared/src/index.ts | 2 +- apps/shared/src/skill-scaffold.ts | 9 --------- ui-tui/src/app/createSlashHandler.ts | 11 ++++++----- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 5bfccf1be7a..ccbdac0c408 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -1,6 +1,6 @@ import { type MutableRefObject, useCallback, useRef } from 'react' -import { dispatchDisplayText } from '@hermes/shared' +import { skillInvocationText } from '@hermes/shared' import { getProfiles } from '@/hermes' import type { Translations } from '@/i18n' @@ -268,8 +268,10 @@ export function useSlashCommand(deps: SlashCommandDeps) { // A skill/bundle dispatch's `message` is the expanded skill body — // model-facing scaffolding. Never render it; the bubble shows the - // invocation instead. - const displayText = dispatchDisplayText('display' in dispatch ? dispatch.display : undefined, message) + // invocation the gateway projected, or one read from the payload + // when the backend is older than this app. + const projected = 'display' in dispatch ? dispatch.display?.trim() : '' + const displayText = projected || skillInvocationText(message) || undefined // Gate on the TARGET session's own busy state, not the foreground // view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat diff --git a/apps/shared/package.json b/apps/shared/package.json index e3482d8a466..2a2f46ef73d 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -8,7 +8,6 @@ "./billing": "./src/billing-types.ts", "./billing-policy": "./src/billing-policy.ts", "./charge-settlement": "./src/charge-settlement.ts", - "./skill-scaffold": "./src/skill-scaffold.ts", "./skin": "./src/skin.ts" }, "types": "./src/index.ts", diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 2339c97716f..391a3715bd2 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -44,7 +44,7 @@ export { JsonRpcGatewayClient, type WebSocketLike } from './json-rpc-gateway' -export { dispatchDisplayText, skillInvocationText } from './skill-scaffold' +export { skillInvocationText } from './skill-scaffold' export { type HermesSkin, SKIN_BRANDING_TOKENS, diff --git a/apps/shared/src/skill-scaffold.ts b/apps/shared/src/skill-scaffold.ts index a06ce3d567a..fffae405b2a 100644 --- a/apps/shared/src/skill-scaffold.ts +++ b/apps/shared/src/skill-scaffold.ts @@ -67,12 +67,3 @@ export function skillInvocationText(text: string): null | string { return instruction ? `${label} ${instruction.replace(/\s+/g, ' ')}` : label } - -/** - * What a skill/bundle dispatch should render as. Prefers the gateway's own - * projection; falls back to reading the payload for an older gateway that - * doesn't send one. Undefined for an ordinary send, which renders as written. - */ -export function dispatchDisplayText(display: string | undefined, message: string): string | undefined { - return display?.trim() || skillInvocationText(message) || undefined -} diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 9a50f77b617..f0d38257e26 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,5 +1,3 @@ -import { dispatchDisplayText } from '@hermes/shared/skill-scaffold' - import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' @@ -107,10 +105,13 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } // A skill/bundle dispatch's `message` is the expanded skill body — - // model-facing scaffolding. The transcript shows the invocation instead; - // an ordinary send has no projection and goes through unchanged. + // model-facing scaffolding. `display` is the invocation the gateway + // projected; the transcript shows that instead. An ordinary send has no + // projection and goes through unchanged. No client-side fallback here: + // the TUI spawns its gateway from this same checkout, so the two can't + // version-skew (unlike the desktop, which can meet an older backend). const sendDispatch = (display: string | undefined, message: string) => { - const shown = dispatchDisplayText(display, message) + const shown = display?.trim() return shown ? send(message, true, shown) : send(message) } From 42fc6227f6b95a09636cdd5e9fe2692b3869c8f3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 04:03:48 -0500 Subject: [PATCH 7/9] fix(ci): sort desktop imports and stub skill display in TUI test Eslint wants @hermes/shared before react, and the slash handler only passes a display override when command.dispatch includes one. --- .../src/app/session/hooks/use-prompt-actions/slash.ts | 3 +-- ui-tui/src/__tests__/createSlashHandler.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index ccbdac0c408..ad30e30cae6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -1,6 +1,5 @@ -import { type MutableRefObject, useCallback, useRef } from 'react' - import { skillInvocationText } from '@hermes/shared' +import { type MutableRefObject, useCallback, useRef } from 'react' import { getProfiles } from '@/hermes' import type { Translations } from '@/i18n' diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b5949362add..d4c8f6c44ce 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -825,7 +825,12 @@ describe('createSlashHandler', () => { } if (method === 'command.dispatch') { - return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' }) + return Promise.resolve({ + type: 'skill', + message: skillMessage, + name: 'hermes-agent-dev', + display: '/hermes-agent-dev' + }) } return Promise.resolve({}) From dddfe6e4a3cf7b21fa210818928ba592b6476036 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 04:12:48 -0500 Subject: [PATCH 8/9] test(gateway): expect display on skill-bundle slash payloads command.dispatch / slash.exec now project a display invocation for bundle sends; update the protocol assertions to match. --- tests/tui_gateway/test_protocol.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index a32fea2768f..7e55821301f 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1592,6 +1592,8 @@ def test_slash_exec_routes_custom_skill_bundle_away_from_worker(server): "type": "send", "message": fake_msg, "notice": "⚡ Loading bundle: analysis-pack (2 skills)", + # UIs render this invocation; `message` stays model-facing scaffolding. + "display": "/analysis-pack", } assert worker.calls == [] @@ -2018,6 +2020,8 @@ def test_command_dispatch_returns_custom_bundle_payload(server): "type": "send", "message": fake_msg, "notice": "⚡ Loading bundle: review-suite (3 skills)", + # UIs render this invocation; `message` stays model-facing scaffolding. + "display": "/review-suite", } build_bundle.assert_called_once_with( "/review-suite", From 1faed58537c35f3bf3b3c5c0bdd5d43e0d470fa2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 04:13:16 -0500 Subject: [PATCH 9/9] chore: kick CI