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.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 03:44:23 -05:00
parent dd39ec2694
commit b700f9f253
4 changed files with 141 additions and 0 deletions

View file

@ -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",

View file

@ -44,6 +44,7 @@ export {
JsonRpcGatewayClient,
type WebSocketLike
} from './json-rpc-gateway'
export { dispatchDisplayText, skillInvocationText } from './skill-scaffold'
export {
type HermesSkin,
SKIN_BRANDING_TOKENS,

View file

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

View file

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