agent-shell / tests /shell /compose.test.ts
uos1231234's picture
agent-shell published to Hugging Face
06c3755
Raw History Blame Contribute Delete
7.05 kB
import { describe, it, expect } from 'vitest'
import { compose, type PromptPart } from '../../src/shell/compose.js'
import type { ToolRegistry } from '../../src/shell/registry.js'
import { ToolRegistry as TR } from '../../src/shell/registry.js'
import type { ChatMessage } from '../../src/protocol/types.js'
const echoParams = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'] } as const
const newRegistry = (): ToolRegistry => {
const r: ToolRegistry = new TR()
r.registerSystemTool({ name: 'echo', description: 'echo back', parameters: echoParams, execute: async (a) => a })
r.registerSkill({ name: 'plan', description: 'plan a task', execute: async () => ({ ok: true }) })
r.registerMCP('gh', [{ name: 'issue', description: 'create issue', parameters: echoParams, execute: async (a) => a }])
return r
}
describe('shell/compose', () => {
it('places system + userTemplate in the first messages, in order', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'TEMPLATE' },
])
expect(out.messages[0]).toEqual({ role: 'system', content: 'SYS' })
expect(out.messages[1]).toEqual({ role: 'user', content: 'TEMPLATE' })
})
it('appends turn messages in order after the template', () => {
const t1: ChatMessage = { role: 'user', content: 'hi' }
const t2: ChatMessage = { role: 'assistant', content: 'hello' }
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
{ type: 'turn', message: t1 },
{ type: 'turn', message: t2 },
])
expect(out.messages).toEqual([
{ role: 'system', content: 'SYS' },
{ role: 'user', content: 'T' },
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
])
})
it('passes through assistant tool_calls and tool results in turns', () => {
const t1: ChatMessage = {
role: 'assistant',
content: null,
tool_calls: [{ id: 'tc-1', type: 'function', function: { name: 'echo', arguments: '{"x":"hi"}' } }],
}
const t2: ChatMessage = { role: 'tool', tool_call_id: 'tc-1', content: '{"echo":"hi"}' }
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
{ type: 'turn', message: t1 },
{ type: 'turn', message: t2 },
])
expect(out.messages[2]).toEqual(t1)
expect(out.messages[3]).toEqual(t2)
})
it('resolves systemTool references into the tools array; mcp/skill are now no-ops (v0.18 progressive disclosure)', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
{ type: 'systemTool', ref: 'echo' },
{ type: 'mcp', server: 'gh', refs: ['issue'] },
{ type: 'skill', ref: 'plan' },
])
const names = out.tools.map(t => t.function.name).sort()
// v0.18: mcp/skill parts are no-ops — tools are loaded dynamically via load_tools.
expect(names).toEqual(['echo'])
})
it('ignores a systemTool ref that is not registered (no crash, no entry)', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
{ type: 'systemTool', ref: 'nonexistent' },
])
expect(out.tools).toEqual([])
})
it('emits an empty tools array when no tool parts are present', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
])
expect(out.tools).toEqual([])
})
it('emits a single system message even if both system and userTemplate are provided', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'TEMPLATE' },
])
expect(out.messages.filter(m => m.role === 'system').length).toBe(1)
expect(out.messages.filter(m => m.role === 'user').length).toBe(1)
})
// ---------- v0.10.6: skillText part (pre-injection into system prompt) ----------
it('skillText appends content to the last system message, separated by \\n\\n', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'BASE SYSTEM' },
{ type: 'userTemplate', content: 'T' },
{ type: 'skillText', content: 'STYLE RULES', skillName: 'style-guide' },
])
// There is still exactly one system message (skillText appends, not inserts).
const sysMsgs = out.messages.filter((m) => m.role === 'system')
expect(sysMsgs).toHaveLength(1)
expect(sysMsgs[0]!.content).toBe('BASE SYSTEM\n\nSTYLE RULES')
})
it('skillText creates a new system message when none exists yet', () => {
// Defensive path: loop.ts always emits a system part first, but compose
// must still work if the caller omitted it. skillText creates a system
// message holding just the skill content.
const out = compose(newRegistry(), [
{ type: 'skillText', content: 'ONLY SKILL', skillName: 'guide' },
{ type: 'userTemplate', content: 'T' },
])
const sysMsgs = out.messages.filter((m) => m.role === 'system')
expect(sysMsgs).toHaveLength(1)
expect(sysMsgs[0]!.content).toBe('ONLY SKILL')
})
it('multiple skillText parts stack under the same system message in order', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'skillText', content: 'RULES-A', skillName: 'a' },
{ type: 'skillText', content: 'RULES-B', skillName: 'b' },
{ type: 'skillText', content: 'RULES-C', skillName: 'c' },
])
const sysMsgs = out.messages.filter((m) => m.role === 'system')
expect(sysMsgs).toHaveLength(1)
expect(sysMsgs[0]!.content).toBe('SYS\n\nRULES-A\n\nRULES-B\n\nRULES-C')
})
it('skillText does NOT add an entry to the tools array (pure pre-injection)', () => {
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'skillText', content: 'BODY', skillName: 'style-guide' },
])
expect(out.tools).toEqual([])
})
it('skillText after turns appends to the earlier system message (not a turn neighbor)', () => {
// The system message was already created before the turns; skillText
// finds it by scanning backwards and appends to it. It must NOT create a
// new system message between turns.
const out = compose(newRegistry(), [
{ type: 'system', content: 'SYS' },
{ type: 'userTemplate', content: 'T' },
{ type: 'turn', message: { role: 'user', content: 'hi' } },
{ type: 'skillText', content: 'LATE SKILL', skillName: 'guide' },
])
const sysMsgs = out.messages.filter((m) => m.role === 'system')
expect(sysMsgs).toHaveLength(1)
expect(sysMsgs[0]!.content).toBe('SYS\n\nLATE SKILL')
// The skillText did not insert a new message between turns.
expect(out.messages.map((m) => m.role)).toEqual(['system', 'user', 'user'])
})
})