From a2641b7d290382d0fe64fbd8bf9aeb4b6d7dda88 Mon Sep 17 00:00:00 2001 From: "tangjunyan.5703" Date: Sat, 22 Aug 2026 20:54:32 +0800 Subject: [PATCH] feat(host): surface skill-tool loads as tagged tool results + Skill inject event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill load via the `skill` tool returns the loaded skill's instructions as a tool result. Keep it a tool result (that is what it is), but make it findable: tag the node with the skill name (so the Context browser labels the row `Skill · `) and record an inject event so a `Skill 注入(name)` entry shows in the Context Events card. - fold: detect tool/result with node.tool === 'skill', parse the skill name from the rendered wrapper, set node.skill, emit a sub:'skill' inject event. Category stays 'tool'. - browser: tool row shows the Skill · tag when node.skill is set. - i18n: add node.skillTag (zh/en). - test: regression for the skill-tool result path. --- src/client/components/browser.tsx | 4 ++- src/client/i18n.ts | 2 ++ src/host/fold.ts | 46 ++++++++++++++++++++++++++++++- tests/host.spec.ts | 38 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 585e228..8b87957 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -783,7 +783,9 @@ export function makeContextBrowser( let tag: string | null = null let preview = nodeText(n) if (n.cat === 'tool') { - tag = (n.tool ?? '?') + (n.err ? ' ⚠' : '') + // A `skill`-tool result is a loaded skill: label it by NAME (not the + // generic tool name) so it scans apart from ordinary tool results. + tag = n.skill ? t('node.skillTag', { name: n.skill }) : (n.tool ?? '?') + (n.err ? ' ⚠' : '') // A call that summarizes itself (bash's `description`, the path // of an edit/read call) previews with that line in the collapsed // row; the generic result label only when the call says nothing. diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 7e726f0..2121f53 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -72,6 +72,7 @@ export const DICT_ZH: Record = { 'node.empty': '(空回复)', 'node.nonText': '(非文本消息)', 'node.snapshot': '快照: ', + 'node.skillTag': 'Skill · {name}', 'cmd.desc': '查看当前上下文构成,浏览各步骤组成', 'cmd.close': '关闭', 'browser.title': '上下文浏览器', @@ -189,6 +190,7 @@ export const DICT_EN: Record = { 'node.empty': '(empty reply)', 'node.nonText': '(non-text message)', 'node.snapshot': 'Snapshot: ', + 'node.skillTag': 'Skill · {name}', 'cmd.desc': 'View current context makeup, browse per-step composition', 'cmd.close': 'Close', 'browser.title': 'Context Browser', diff --git a/src/host/fold.ts b/src/host/fold.ts index 1064494..f97826c 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -242,6 +242,35 @@ interface MessageLike { error?: boolean } +/** + * The first full text block, recursing through nested content blocks (a tool + * result wraps its text in a `tool-result` block). Unlike `firstText` this + * must NOT truncate/normalize: the skill name is matched off the raw + * `` wrapper. + */ +function nestedText(blocks: ContentBlock[] | undefined): string { + if (!Array.isArray(blocks)) return '' + for (const block of blocks) { + if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') return block.text + if (block.content !== undefined) { + const nested = nestedText(block.content) + if (nested !== '') return nested + } + } + return '' +} + +/** + * The skill name a `skill`-tool result carries. Loaded skills are rendered as + * `…` in the result's text, so the name + * is recovered from the content rather than trusted from the call envelope. + */ +function skillNameOf(msg: MessageLike | null | undefined): string { + const text = nestedText(msg?.content) + const match = text.match(/`. + if (node.cat === 'tool' && node.tool === 'skill') { + const name = skillNameOf(toolMsg) + if (name !== '') { + node.skill = name + s.events.push({ seq: event.seq, time: event.time, kind: 'inject', form: 'instructions', sub: 'skill', name, tokens: node.tokens }) + } + } break } case 'assistant/message': { diff --git a/tests/host.spec.ts b/tests/host.spec.ts index 840ab01..9b6a82b 100644 --- a/tests/host.spec.ts +++ b/tests/host.spec.ts @@ -517,3 +517,41 @@ test('host HMR safety: fiber dispose removes both projection registrations', () for (const remove of removers) remove() // simulate fiber dispose assert.equal(defs.size, 0, 'fiber dispose removes every registration') }) + +test('host: a skill-tool result is tagged and surfaced as a Skill injection while staying a tool result', () => { + const defs = new Map() + const fakeCtx = { + inject(list, cb) { cb(this) }, + effect() { return () => {} }, + sessionProjections: { register(d) { defs.set(d.key, d); return () => {} } }, + } + apply(fakeCtx) + const def = defs.get('contextTimeline') + const drive = (events) => { + let st = def.init() + for (const ev of events) st = def.apply(st, ev) + return def.view(st) + } + const events = [ + { seq: 1, type: 'request/header', time: 1000, data: { header: { system: 's', tools: [{ name: 'skill' }], config: { model: 'deepseek-v4' } } } }, + { seq: 2, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'skill', arguments: '{"name":"code-review"}' } }, + { seq: 3, type: 'tool/result', time: 4100, data: { callId: 'c1', message: { + source: { kind: 'tool', callId: 'c1' }, + content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: '\nRun a thorough review.\n' }] }], + } } }, + { seq: 4, type: 'assistant/message', time: 5000, data: { turn: 1, step: 1, usage: { inputTokens: 10 }, message: { content: [{ type: 'text', text: 'ok' }] } } }, + ] + const v = drive(events) + const node = v.nodes.find(n => n.seq === 3) + assert.equal(node.cat, 'tool', 'skill tool result stays a tool node (skill is a tool)') + assert.equal(node.tool, 'skill', 'node names the skill tool') + assert.equal(node.skill, 'code-review', 'node is tagged with the loaded skill name') + const inject = v.events.find(e => e.kind === 'inject') + assert.ok(inject, 'skill load emits an inject event (so it lists in Context Events)') + assert.equal(inject.sub, 'skill', 'event sub-carries the skill kind') + assert.equal(inject.name, 'code-review', 'event names the loaded skill') + assert.equal(inject.form, 'instructions', 'event uses the instructions form') + assert.ok(inject.tokens > 0, 'event carries the skill content token cost') + assert.equal(inject.turn, 1, 'inject attributed to the turn that consumed it') + assert.equal(inject.step, 1, 'inject attributed to the step that consumed it') +})