fix(tui): show the invocation for a skill send, not the skill body

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 03:44:31 -05:00
parent b700f9f253
commit 5a940180b4
7 changed files with 46 additions and 17 deletions

View file

@ -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 () => {

View file

@ -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') {

View file

@ -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<Msg[]>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]

View file

@ -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…' })

View file

@ -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]

View file

@ -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 ───────────────────────────────────────────────────────────

View file

@ -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)
}
}