Files
hapi/cli/src/agent/runners/runAgentSession.test.ts
T
af962fc61f fix(cli): stop prepending skill_lookup $name instruction onto user turns (#1096)
Cursor ACP (and other remotes) flagged the glued-on SKILL_LOOKUP_INSTRUCTION
as prompt injection. Keep discovery on the skill_lookup MCP tool description
and on system prompts (OpenCode/Grok); do not taint user messages.

Fixes #1095

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 18:29:29 +01:00

190 lines
6.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const harness = vi.hoisted(() => ({
sendSessionDeath: vi.fn(),
userMessageHandler: null as null | ((message: { content: { text: string; attachments: unknown[] } }, localId: string) => void),
promptError: null as Error | null,
cancelPrompt: vi.fn(async () => {}),
cancelAll: vi.fn(async () => {}),
stopServer: vi.fn(),
disconnect: vi.fn(async () => {}),
prompts: [] as unknown[][],
startHappyServerOptions: null as unknown,
bridgeArgs: [] as string[],
newSessionOptions: null as unknown,
killHandler: null as null | (() => Promise<void>)
}))
vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async () => ({
session: {
updateAgentState: vi.fn(),
onUserMessage: vi.fn((handler) => {
harness.userMessageHandler = handler
}),
onCancelQueuedMessage: vi.fn(),
keepAlive: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
sendSessionDeath: harness.sendSessionDeath,
flush: vi.fn(async () => {}),
close: vi.fn(),
rpcHandlerManager: {
registerHandler: vi.fn()
}
},
sessionInfo: {
permissionMode: 'default'
}
}))
}))
vi.mock('@/agent/AgentRegistry', () => ({
AgentRegistry: {
create: vi.fn(() => ({
initialize: vi.fn(async () => {}),
newSession: vi.fn(async (options: unknown) => {
harness.newSessionOptions = options
return 'agent-session-1'
}),
prompt: vi.fn(async (_sessionId: string, content: unknown[]) => {
harness.prompts.push(content)
if (harness.promptError) {
throw harness.promptError
}
}),
cancelPrompt: harness.cancelPrompt,
respondToPermission: vi.fn(async () => {}),
onPermissionRequest: vi.fn(),
disconnect: harness.disconnect
}))
}
}))
vi.mock('@/agent/permissionAdapter', () => ({
PermissionAdapter: vi.fn(function PermissionAdapter() {
return {
cancelAll: harness.cancelAll
}
})
}))
vi.mock('@/claude/utils/startHappyServer', () => ({
startHappyServer: vi.fn(async (_session: unknown, options: unknown) => {
harness.startHappyServerOptions = options
return {
url: 'http://127.0.0.1:1234',
toolNames: ['change_title', 'display_image', 'skill_lookup'],
stop: harness.stopServer
}
})
}))
vi.mock('@/utils/spawnHappyCLI', () => ({
getHappyCliCommand: vi.fn((args: string[]) => {
harness.bridgeArgs = args
return { command: 'hapi', args, env: [] }
})
}))
vi.mock('@/claude/registerKillSessionHandler', () => ({
registerKillSessionHandler: vi.fn((_manager: unknown, handler: () => Promise<void>) => {
harness.killHandler = handler
})
}))
vi.mock('@/utils/invokedCwd', () => ({
getInvokedCwd: vi.fn(() => '/tmp/project')
}))
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn(),
warn: vi.fn()
}
}))
vi.mock('@/utils/attachmentFormatter', () => ({
formatMessageWithAttachments: vi.fn((text: string) => text)
}))
import { runAgentSession } from './runAgentSession'
describe('runAgentSession', () => {
beforeEach(() => {
harness.sendSessionDeath.mockClear()
harness.userMessageHandler = null
harness.promptError = null
harness.cancelPrompt.mockClear()
harness.cancelAll.mockClear()
harness.stopServer.mockClear()
harness.disconnect.mockClear()
harness.prompts = []
harness.startHappyServerOptions = null
harness.bridgeArgs = []
harness.newSessionOptions = null
harness.killHandler = null
})
it('reports unhandled ACP runner failures as error, not completed', async () => {
harness.cancelAll.mockImplementationOnce(async () => {
throw new Error('cancel failed')
})
const running = runAgentSession({ agentType: 'acp' })
for (let i = 0; i < 5; i++) {
await Promise.resolve()
}
expect(harness.userMessageHandler).not.toBeNull()
harness.userMessageHandler?.({ content: { text: 'hello', attachments: [] } }, 'local-1')
await expect(running).rejects.toThrow('cancel failed')
expect(harness.sendSessionDeath).toHaveBeenCalledWith('error')
expect(harness.sendSessionDeath).not.toHaveBeenCalledWith('completed')
})
it('enables skill lookup and injects its instruction only on the first prompt', async () => {
const running = runAgentSession({ agentType: 'acp' })
await vi.waitFor(() => expect(harness.userMessageHandler).not.toBeNull())
harness.userMessageHandler?.({ content: { text: 'first', attachments: [] } }, 'local-1')
await vi.waitFor(() => expect(harness.prompts).toHaveLength(1))
harness.userMessageHandler?.({ content: { text: 'second', attachments: [] } }, 'local-2')
await vi.waitFor(() => expect(harness.prompts).toHaveLength(2))
await harness.killHandler?.()
await running
expect(harness.startHappyServerOptions).toEqual({
skillLookup: {
workingDirectory: '/tmp/project',
flavor: 'acp'
}
})
expect(harness.bridgeArgs).toEqual([
'mcp',
'--url',
'http://127.0.0.1:1234',
'--tools',
'change_title,display_image,skill_lookup'
])
expect(harness.newSessionOptions).toMatchObject({
cwd: '/tmp/project',
mcpServers: [{
name: 'happy',
command: 'hapi',
args: harness.bridgeArgs
}]
})
const firstPrompt = JSON.stringify(harness.prompts[0])
const secondPrompt = JSON.stringify(harness.prompts[1])
expect(firstPrompt).toContain('first')
expect(firstPrompt).not.toContain('skill_lookup')
expect(firstPrompt).not.toContain('$name')
expect(secondPrompt).toContain('second')
expect(secondPrompt).not.toContain('skill_lookup')
})
})