Files
PiAgentUI/extensions/piagentui-server.test.ts
T
evandro 3dd3fa5d05 test: read SSE chunks until session.updated arrives
session.status and session.updated are two separate res.write calls, so the
test failed whenever TCP delivered them in different chunks.
2026-10-10 04:23:16 -03:00

1049 lines
39 KiB
TypeScript

import * as os from 'node:os'
import * as path from 'node:path'
import * as fs from 'node:fs'
import * as nodeHttp from 'node:http'
import { spawnSync } from 'node:child_process'
const tempHomeDir = path.join(os.tmpdir(), `piagentui-test-home-${Math.random().toString(36).substring(2)}`)
fs.mkdirSync(tempHomeDir, { recursive: true })
const originalUserProfile = process.env.USERPROFILE
const originalHome = process.env.HOME
process.env.USERPROFILE = tempHomeDir
process.env.HOME = tempHomeDir
import { describe, it, expect, vi, afterAll } from 'vitest'
import serverInit from './piagentui-server.js'
describe('PiAgentUi Extension Server', () => {
afterAll(() => {
// Clean up temp directory
try {
if (fs.existsSync(tempHomeDir)) {
fs.rmSync(tempHomeDir, { recursive: true, force: true })
}
} catch {
// ignore
}
process.env.USERPROFILE = originalUserProfile
process.env.HOME = originalHome
})
it('should initialize server, listen on a port, write discovery info, and respond to requests', async () => {
// Mock ExtensionAPI
const listeners: Record<string, (...args: any[]) => any> = {}
const mockPi = {
on: (event: string, callback: (...args: any[]) => any) => {
listeners[event] = callback
},
setModel: vi.fn(),
sendUserMessage: vi.fn(),
getCommands: vi.fn(() => [
{
name: 'review',
description: 'Run project review',
source: 'extension',
sourceInfo: { path: '/mock/extensions/review.ts', scope: 'project' },
},
{
name: 'release',
description: 'Use release prompt',
source: 'prompt',
sourceInfo: { path: '/mock/prompts/release.md', scope: 'global' },
},
{
name: 'skill:test-driven-development',
description: 'Use TDD skill',
source: 'skill',
sourceInfo: { path: '/mock/skills/test-driven-development/SKILL.md', scope: 'global' },
},
]),
}
// Initialize extension server (registers event listeners on mockPi)
serverInit(mockPi as any)
expect(listeners['session_start']).toBeDefined()
expect(listeners['session_shutdown']).toBeDefined()
// Create session and skill directories
const tempSessionDir = path.join(tempHomeDir, 'sessions')
fs.mkdirSync(tempSessionDir, { recursive: true })
const projectDir = path.join(tempHomeDir, 'project')
const globalSkillFile = path.join(tempHomeDir, '.agents', 'skills', 'global-skill', 'SKILL.md')
const projectSkillFile = path.join(projectDir, '.pi', 'skills', 'project-skill', 'SKILL.md')
const packageSkillFile = path.join(
tempHomeDir,
'.pi',
'agent',
'npm',
'node_modules',
'demo-package',
'skills',
'package-skill',
'SKILL.md',
)
const mcpConfigFile = path.join(tempHomeDir, '.pi', 'agent', 'mcp.json')
const piSettingsFile = path.join(tempHomeDir, '.pi', 'agent', 'settings.json')
const extensionPackageRoot = path.join(tempHomeDir, '.pi', 'agent', 'npm', 'node_modules', 'pi-demo')
fs.mkdirSync(path.dirname(globalSkillFile), { recursive: true })
fs.mkdirSync(path.dirname(projectSkillFile), { recursive: true })
fs.writeFileSync(path.join(projectDir, 'README.md'), 'PiAgentUI test repo\n', 'utf8')
spawnSync('git', ['init', '-b', 'main'], { cwd: projectDir })
spawnSync('git', ['config', 'user.email', '[email protected]'], { cwd: projectDir })
spawnSync('git', ['config', 'user.name', 'PiAgentUI Test'], { cwd: projectDir })
spawnSync('git', ['add', 'README.md'], { cwd: projectDir })
spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: projectDir })
fs.mkdirSync(path.dirname(packageSkillFile), { recursive: true })
fs.mkdirSync(path.dirname(mcpConfigFile), { recursive: true })
fs.mkdirSync(path.join(extensionPackageRoot, 'extensions'), { recursive: true })
fs.writeFileSync(
mcpConfigFile,
JSON.stringify({
mcpServers: {
context7: {
type: 'http',
url: 'https://mcp.context7.com/mcp',
description: 'Context7 docs',
directTools: true,
lifecycle: 'lazy',
},
localTool: {
command: 'npx',
args: ['local-mcp'],
},
},
}),
)
fs.writeFileSync(piSettingsFile, JSON.stringify({ packages: ['npm:pi-demo'] }), 'utf8')
fs.writeFileSync(
path.join(extensionPackageRoot, 'package.json'),
JSON.stringify({
name: 'pi-demo',
version: '1.0.0',
description: 'Demo Pi extension package',
pi: { extensions: ['./extensions'] },
}),
'utf8',
)
fs.writeFileSync(path.join(extensionPackageRoot, 'extensions', 'demo.ts'), 'export default {}', 'utf8')
fs.writeFileSync(
globalSkillFile,
'---\nname: global-skill\ndescription: Global skill description\n---\n\nGlobal content',
)
fs.writeFileSync(
projectSkillFile,
'---\nname: project-skill\ndescription: Project skill description\n---\n\nProject content',
)
fs.writeFileSync(
packageSkillFile,
'---\nname: package-skill\ndescription: Package skill description\n---\n\nPackage content',
)
// Create a mock active session file
const activeSessionId = 'test-session-uuid-12345'
const sessionFile = path.join(tempSessionDir, `session_${activeSessionId}.jsonl`)
fs.writeFileSync(
sessionFile,
JSON.stringify({
type: 'session',
id: activeSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) + '\n',
)
const otherSessionId = 'test-session-uuid-other'
const otherSessionFile = path.join(tempSessionDir, `session_${otherSessionId}.jsonl`)
fs.writeFileSync(
otherSessionFile,
JSON.stringify({
type: 'session',
id: otherSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) +
'\n' +
JSON.stringify({
type: 'message',
id: 'other-user-1',
message: { role: 'user', content: [{ type: 'text', text: 'Investigate Docker access' }] },
}) +
'\n',
)
const namedSessionId = 'named-session-uuid'
const namedSessionFile = path.join(tempSessionDir, `session_${namedSessionId}.jsonl`)
fs.writeFileSync(
namedSessionFile,
JSON.stringify({
type: 'session',
id: namedSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) +
'\n' +
JSON.stringify({ type: 'session_info', id: 'named-info-1', name: 'Manual Session Name' }) +
'\n' +
JSON.stringify({
type: 'message',
id: 'named-user-1',
message: { role: 'user', content: [{ type: 'text', text: 'This should not be the title' }] },
}) +
'\n',
)
const noopSessionId = 'noop-session-uuid'
const noopSessionFile = path.join(tempSessionDir, `session_${noopSessionId}.jsonl`)
fs.writeFileSync(
noopSessionFile,
JSON.stringify({
type: 'session',
id: noopSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) + '\n',
)
const cancelledSessionId = 'test-session-uuid-cancelled'
const cancelledSessionFile = path.join(tempSessionDir, `session_${cancelledSessionId}.jsonl`)
fs.writeFileSync(
cancelledSessionFile,
JSON.stringify({
type: 'session',
id: cancelledSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) + '\n',
)
const replacedSendUserMessage = vi.fn()
const replacedSetModel = vi.fn()
const switchSession = vi.fn(
async (sessionPath: string, options?: { withSession?: (ctx: any) => Promise<void> }) => {
await listeners['session_shutdown']?.({ reason: 'resume' })
const sessionIdFromPath =
path
.basename(sessionPath)
.replace(/\.jsonl$/, '')
.split('_')
.pop() || otherSessionId
await options?.withSession?.({
...mockContext,
sessionManager: {
getSessionDir: () => tempSessionDir,
getSessionId: () => sessionIdFromPath,
},
model: undefined,
setModel: replacedSetModel,
sendUserMessage: replacedSendUserMessage,
})
return { cancelled: false }
},
)
// Mock ExtensionContext
const mockContext = {
sessionManager: {
getSessionDir: () => tempSessionDir,
getSessionId: () => activeSessionId,
},
modelRegistry: {
getAvailable: () => [
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
provider: 'google',
contextWindow: 200000,
maxOutputTokens: 64000,
reasoning: true,
input: ['text', 'image'],
},
{
id: 'legacy-max-tokens-model',
name: 'Legacy Max Tokens Model',
provider: 'anthropic',
contextWindow: 300000,
maxTokens: 32000,
},
],
getAll: () => [],
find: (provider: string, id: string) => ({ id, provider, name: id }),
},
model: {
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
provider: 'google',
contextWindow: 200000,
maxOutputTokens: 64000,
},
setModel: vi.fn(),
cwd: projectDir,
isIdle: () => true,
getContextUsage: vi.fn(() => ({ tokens: 42000, contextWindow: 200000, percent: 21 })),
getSystemPrompt: vi.fn(
() => `<available_skills>
<skill><name>global-skill</name><description>Global skill description</description><location>${globalSkillFile}</location></skill>
<skill><name>project-skill</name><description>Project skill description</description><location>${projectSkillFile}</location></skill>
<skill><name>package-skill</name><description>Package skill description</description><location>${packageSkillFile}</location></skill>
</available_skills>`,
),
abort: vi.fn(),
switchSession,
ui: {
setWidget: vi.fn(),
},
}
// Trigger session_start
await listeners['session_start']({}, mockContext)
// Wait for the async server.listen callback to run and write connection info
await new Promise(resolve => setTimeout(resolve, 200))
// Verify widget is set
expect(mockContext.ui.setWidget).toHaveBeenCalledWith(
'piagentui-link',
expect.arrayContaining([expect.stringContaining('PiAgentUI Web:')]),
expect.objectContaining({ placement: 'belowEditor' }),
)
// Verify piagentui-port.json exists in temp home dir
const discoveryPath = path.join(tempHomeDir, '.pi', 'agent', 'piagentui-port.json')
expect(fs.existsSync(discoveryPath)).toBe(true)
// Read discovery data
const discoveryData = JSON.parse(fs.readFileSync(discoveryPath, 'utf8'))
const { port, token } = discoveryData
expect(port).toBeGreaterThan(0)
expect(token).toBeDefined()
const baseUrl = `http://127.0.0.1:${port}`
// 1. Test /global/health
const healthRes = await fetch(`${baseUrl}/global/health`)
expect(healthRes.status).toBe(200)
const healthData = await healthRes.json()
expect(healthData).toEqual({ status: 'ok', pi: 'ready' })
// 2. Test unauthorized endpoint
const unauthRes = await fetch(`${baseUrl}/api/models`)
expect(unauthRes.status).toBe(401)
// 3. Test /api/models with correct token
const modelsRes = await fetch(`${baseUrl}/api/models`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(modelsRes.status).toBe(200)
const modelsData = (await modelsRes.json()) as any
expect(modelsData.models).toBeDefined()
expect(modelsData.models[0].id).toBe('gemini-2.5-pro')
expect(modelsData.models[0].contextLimit).toBe(200000)
expect(modelsData.models[0].outputLimit).toBe(64000)
const legacyModel = modelsData.models.find((model: any) => model.id === 'legacy-max-tokens-model')
expect(legacyModel.contextLimit).toBe(300000)
expect(legacyModel.outputLimit).toBe(32000)
// 4. Test /api/network/access
const networkRes = await fetch(`${baseUrl}/api/network/access`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(networkRes.status).toBe(200)
const networkData = (await networkRes.json()) as any
expect(networkData).toMatchObject({
lanAccessEnabled: false,
port,
localUrl: `http://127.0.0.1:${port}`,
})
expect(Array.isArray(networkData.lanUrls)).toBe(true)
const networkEnableRes = await fetch(`${baseUrl}/api/network/access`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ lanAccessEnabled: true }),
})
expect(networkEnableRes.status).toBe(200)
const networkEnableData = (await networkEnableRes.json()) as any
expect(networkEnableData.lanAccessEnabled).toBe(true)
if (networkEnableData.primaryLanUrl) {
expect(networkEnableData.browserLanUrl).toBe(networkEnableData.primaryLanUrl)
expect(networkEnableData.browserLanUrl).not.toContain('token=')
}
const lanModelsRes = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = nodeHttp.request(
{
host: '127.0.0.1',
port,
path: '/api/models',
method: 'GET',
headers: { Host: `192.168.1.25:${port}` },
},
response => {
let body = ''
response.setEncoding('utf8')
response.on('data', chunk => {
body += chunk
})
response.on('end', () => resolve({ status: response.statusCode || 0, body }))
},
)
req.on('error', reject)
req.end()
})
expect(lanModelsRes.status).toBe(200)
const lanPageRes = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = nodeHttp.request(
{
host: '127.0.0.1',
port,
path: '/',
method: 'GET',
headers: { Host: `192.168.1.25:${port}` },
},
response => {
let body = ''
response.setEncoding('utf8')
response.on('data', chunk => {
body += chunk
})
response.on('end', () => resolve({ status: response.statusCode || 0, body }))
},
)
req.on('error', reject)
req.end()
})
expect(lanPageRes.status).toBe(200)
expect(lanPageRes.body).toContain('token: null')
// 5. Test STT settings and file transcription without leaking API key
const sttSettingsRes = await fetch(`${baseUrl}/api/settings/stt`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(sttSettingsRes.status).toBe(200)
const initialSttSettings = (await sttSettingsRes.json()) as any
expect(initialSttSettings.apiKeyConfigured).toBe(false)
expect(initialSttSettings.apiKey).toBeUndefined()
const saveSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
providerKind: 'openai-compatible',
mode: 'file',
baseUrl: 'https://api.openai.test/v1',
apiKey: 'secret-stt-key',
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
language: 'pt',
insertMode: 'append',
}),
})
expect(saveSttRes.status).toBe(200)
const savedSttSettings = (await saveSttRes.json()) as any
expect(savedSttSettings.apiKeyConfigured).toBe(true)
expect(savedSttSettings.apiKey).toBeUndefined()
const originalFetch = globalThis.fetch
const providerFetch = vi.fn(async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const target = String(input)
if (target === 'https://api.openai.test/v1/audio/transcriptions') {
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer secret-stt-key')
const body = init?.body as FormData
expect(body.get('model')).toBe('gpt-4o-mini-transcribe')
expect(body.get('language')).toBe('pt')
expect(body.get('file')).toBeInstanceOf(File)
return new Response(JSON.stringify({ text: 'texto transcrito' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return originalFetch.call(globalThis, input, init)
})
globalThis.fetch = providerFetch as typeof fetch
try {
const boundary = '----piagentui-test-boundary'
const multipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="voice.webm"',
'Content-Type: audio/webm',
'',
'audio',
`--${boundary}--`,
'',
].join('\r\n')
const transcriptionRes = await originalFetch.call(globalThis, `${baseUrl}/api/stt/transcriptions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(Buffer.byteLength(multipartBody)),
},
body: multipartBody,
})
expect(transcriptionRes.status).toBe(200)
const transcriptionData = (await transcriptionRes.json()) as any
expect(transcriptionData).toMatchObject({
text: 'texto transcrito',
provider: 'openai-compatible',
model: 'gpt-4o-mini-transcribe',
})
expect(providerFetch).toHaveBeenCalledWith(
'https://api.openai.test/v1/audio/transcriptions',
expect.objectContaining({ method: 'POST' }),
)
} finally {
globalThis.fetch = originalFetch
}
const keylessSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
providerKind: 'openai-compatible',
baseUrl: 'http://127.0.0.1:8022/v1',
apiKey: null,
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
}),
})
expect(keylessSttRes.status).toBe(200)
const keylessSttSettings = (await keylessSttRes.json()) as any
expect(keylessSttSettings.apiKeyConfigured).toBe(false)
expect(keylessSttSettings.apiKeyRequired).toBe(false)
const keylessFetch = vi.fn(async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const target = String(input)
if (target === 'http://127.0.0.1:8022/v1/audio/transcriptions') {
expect(init?.headers).toEqual({})
return new Response(JSON.stringify({ text: 'sem chave' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return originalFetch.call(globalThis, input, init)
})
globalThis.fetch = keylessFetch as typeof fetch
try {
const boundary = '----piagentui-keyless-boundary'
const multipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="voice.webm"',
'Content-Type: audio/webm',
'',
'audio',
`--${boundary}--`,
'',
].join('\r\n')
const keylessTranscriptionRes = await originalFetch.call(globalThis, `${baseUrl}/api/stt/transcriptions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(Buffer.byteLength(multipartBody)),
},
body: multipartBody,
})
expect(keylessTranscriptionRes.status).toBe(200)
expect(await keylessTranscriptionRes.json()).toMatchObject({ text: 'sem chave' })
} finally {
globalThis.fetch = originalFetch
}
const saveTtsRes = await fetch(`${baseUrl}/api/settings/tts`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
baseUrl: 'http://127.0.0.1:8022/v1',
apiKeyRequired: false,
speechEndpoint: '/audio/speech',
model: 'speaches-ai/Kokoro-82M-v1.0-ONNX',
voice: 'pf_dora',
responseFormat: 'wav',
}),
})
expect(saveTtsRes.status).toBe(200)
const savedTtsSettings = (await saveTtsRes.json()) as any
expect(savedTtsSettings).toMatchObject({
enabled: true,
apiKeyConfigured: false,
apiKeyRequired: false,
voice: 'pf_dora',
responseFormat: 'wav',
})
const invalidSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl: 'file:///tmp/secrets' }),
})
expect(invalidSttRes.status).toBe(400)
// 6. Test /api/commands/list with built-in and dynamic Pi commands
const commandsRes = await fetch(`${baseUrl}/api/commands/list`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(commandsRes.status).toBe(200)
const commandsData = (await commandsRes.json()) as any
expect(commandsData.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'settings', apiSource: 'builtin', category: 'general' }),
expect.objectContaining({ name: 'model', apiSource: 'builtin', category: 'model' }),
expect.objectContaining({ name: 'compact', apiSource: 'builtin', category: 'context' }),
expect.objectContaining({
name: 'review',
description: 'Run project review',
apiSource: 'extension',
category: 'extension',
}),
expect.objectContaining({ name: 'release', apiSource: 'prompt', category: 'prompt' }),
expect.objectContaining({ name: 'skill:test-driven-development', apiSource: 'skill', category: 'skill' }),
]),
)
expect(commandsData.groups).toMatchObject({ builtin: 21, extension: 1, prompt: 1, skill: 1 })
// 5. Test /api/skills with real Pi prompt metadata
const skillsRes = await fetch(`${baseUrl}/api/skills`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(skillsRes.status).toBe(200)
const skillsData = (await skillsRes.json()) as any
expect(skillsData.skills.map((skill: any) => [skill.name, skill.source])).toEqual([
['global-skill', 'global'],
['project-skill', 'project'],
['package-skill', 'package'],
])
expect(skillsData.skills[0].content).toContain('Global content')
expect(skillsData.groups).toMatchObject({ global: 1, project: 1, package: 1, other: 0 })
// 5. Test /api/mcp/status with real local config metadata
const mcpRes = await fetch(`${baseUrl}/api/mcp/status`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(mcpRes.status).toBe(200)
const mcpData = (await mcpRes.json()) as any
expect(mcpData.configPaths).toEqual([mcpConfigFile])
expect(mcpData.servers.context7).toMatchObject({
status: 'configured',
transport: 'http',
source: 'global',
url: 'https://mcp.context7.com/mcp',
directTools: true,
lifecycle: 'lazy',
})
expect(mcpData.servers.localTool).toMatchObject({
status: 'configured',
transport: 'stdio',
command: 'npx',
args: ['local-mcp'],
})
// 6. Test /api/extensions with configured Pi packages and active resource info
const extensionsRes = await fetch(`${baseUrl}/api/extensions`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(extensionsRes.status).toBe(200)
const extensionsData = (await extensionsRes.json()) as any
expect(extensionsData.summary).toMatchObject({ total: 1, active: 1, inactive: 0, missing: 0, error: 0 })
expect(extensionsData.packages).toEqual([
expect.objectContaining({
source: 'npm:pi-demo',
scope: 'user',
packageName: 'pi-demo',
version: '1.0.0',
description: 'Demo Pi extension package',
status: 'active',
resources: expect.objectContaining({
extensions: [expect.objectContaining({ name: 'demo.ts', enabled: true })],
}),
}),
])
fs.writeFileSync(path.join(projectDir, 'git-control.txt'), 'changed\n', 'utf8')
const vcsStatusRes = await fetch(`${baseUrl}/api/vcs/status?directory=${encodeURIComponent(projectDir)}`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(vcsStatusRes.status).toBe(200)
const vcsStatusData = (await vcsStatusRes.json()) as any
expect(vcsStatusData).toMatchObject({ branch: 'main', dirty: true, ahead: 0, behind: 0 })
expect(vcsStatusData.files).toEqual(
expect.arrayContaining([expect.objectContaining({ path: 'git-control.txt', untracked: true })]),
)
const vcsBranchesRes = await fetch(`${baseUrl}/api/vcs/branches?directory=${encodeURIComponent(projectDir)}`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(vcsBranchesRes.status).toBe(200)
const vcsBranchesData = (await vcsBranchesRes.json()) as any
expect(vcsBranchesData).toMatchObject({ current: 'main' })
expect(vcsBranchesData.branches).toEqual([expect.objectContaining({ name: 'main', current: true, remote: false })])
const stageRes = await fetch(`${baseUrl}/api/vcs/stage?directory=${encodeURIComponent(projectDir)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: ['git-control.txt'] }),
})
expect(stageRes.status).toBe(200)
const commitRes = await fetch(`${baseUrl}/api/vcs/commit?directory=${encodeURIComponent(projectDir)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'test: add git control fixture' }),
})
expect(commitRes.status).toBe(200)
expect(await commitRes.json()).toMatchObject({ ok: true })
// 7. Test /api/sessions/:sessionId/context
const contextRes = await fetch(`${baseUrl}/api/sessions/${activeSessionId}/context`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(contextRes.status).toBe(200)
const contextData = (await contextRes.json()) as any
expect(contextData).toMatchObject({
available: true,
sessionId: activeSessionId,
tokens: 42000,
contextWindow: 200000,
percent: 21,
providerID: 'google',
modelID: 'gemini-2.5-pro',
modelName: 'Gemini 2.5 Pro',
outputLimit: 64000,
})
const inactiveContextRes = await fetch(`${baseUrl}/api/sessions/${otherSessionId}/context`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(inactiveContextRes.status).toBe(200)
const inactiveContextData = (await inactiveContextRes.json()) as any
expect(inactiveContextData).toMatchObject({
available: false,
sessionId: otherSessionId,
activeSessionId,
reason: 'session_not_active',
})
// 5. Test /api/sessions (GET)
const getSessionsRes = await fetch(`${baseUrl}/api/sessions`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(getSessionsRes.status).toBe(200)
const getSessionsData = (await getSessionsRes.json()) as any
expect(getSessionsData.sessions).toBeDefined()
expect(getSessionsData.sessions.map((session: any) => session.id)).toEqual(
expect.arrayContaining([activeSessionId, otherSessionId, namedSessionId, noopSessionId]),
)
const sessionsById = new Map<string, any>(getSessionsData.sessions.map((session: any) => [session.id, session]))
expect(sessionsById.get(otherSessionId)?.title).toBe('Investigate Docker access')
expect(sessionsById.get(namedSessionId)?.title).toBe('Manual Session Name')
expect(sessionsById.get(noopSessionId)?.title).toBe('Session noop-ses')
// 6. Test /api/sessions (POST) creates a real Pi session and switches to it
const createSessionRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ directory: projectDir, title: 'Fresh Real Session' }),
})
expect(createSessionRes.status).toBe(200)
const createSessionData = (await createSessionRes.json()) as any
expect(createSessionData.id).not.toBe(activeSessionId)
expect(createSessionData.title).toBe('Fresh Real Session')
expect(createSessionData.directory).toBe(projectDir)
const createdSessionFiles = fs
.readdirSync(tempSessionDir)
.filter(file => file.endsWith(`_${createSessionData.id}.jsonl`))
expect(createdSessionFiles).toHaveLength(1)
const createdSessionContent = fs.readFileSync(path.join(tempSessionDir, createdSessionFiles[0]), 'utf8')
expect(createdSessionContent).toContain('"type":"session"')
expect(createdSessionContent).toContain('"version":3')
expect(createdSessionContent).toContain(`"cwd":"${projectDir.replace(/\\/g, '\\\\')}"`)
expect(createdSessionContent).toContain('Fresh Real Session')
expect(switchSession).toHaveBeenCalledWith(
path.join(tempSessionDir, createdSessionFiles[0]),
expect.objectContaining({ withSession: expect.any(Function) }),
)
switchSession.mockClear()
// 7. Test /api/sessions/abort (POST)
const abortRes = await fetch(`${baseUrl}/api/sessions/abort`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
expect(abortRes.status).toBe(200)
const abortData = (await abortRes.json()) as any
expect(abortData).toEqual({ status: 'aborted' })
expect(mockContext.abort).toHaveBeenCalled()
// 8. Test /api/messages/send (POST)
const sendRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'hello world',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
}),
})
expect(sendRes.status).toBe(202)
expect(mockPi.sendUserMessage).toHaveBeenCalledWith('hello world')
expect(switchSession).not.toHaveBeenCalled()
const sendOtherRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: otherSessionId,
text: 'hello other session',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
}),
})
expect(sendOtherRes.status).toBe(202)
expect(switchSession).toHaveBeenCalledWith(
otherSessionFile,
expect.objectContaining({
withSession: expect.any(Function),
}),
)
expect(replacedSetModel).toHaveBeenCalledWith({ id: 'gemini-2.5-pro', provider: 'google', name: 'gemini-2.5-pro' })
expect(replacedSendUserMessage).toHaveBeenCalledWith('hello other session')
expect(fs.existsSync(discoveryPath)).toBe(true)
const missingSessionRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: 'missing-session-id',
text: 'must not be sent',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
}),
})
expect(missingSessionRes.status).toBe(404)
expect(replacedSendUserMessage).not.toHaveBeenCalledWith('must not be sent')
switchSession.mockImplementationOnce(async sessionPath => {
expect(sessionPath).toBe(noopSessionFile)
return { cancelled: false }
})
const noopSwitchRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: noopSessionId,
text: 'must not falsely succeed',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
}),
})
expect(noopSwitchRes.status).toBe(501)
expect(replacedSendUserMessage).not.toHaveBeenCalledWith('must not falsely succeed')
switchSession.mockImplementationOnce(async sessionPath => {
expect(sessionPath).toBe(cancelledSessionFile)
return { cancelled: true }
})
const cancelledSwitchRes = await fetch(`${baseUrl}/api/messages/send`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId: cancelledSessionId,
text: 'must not be sent after cancellation',
model: { providerID: 'google', modelID: 'gemini-2.5-pro' },
}),
})
expect(cancelledSwitchRes.status).toBe(409)
expect(replacedSendUserMessage).not.toHaveBeenCalledWith('must not be sent after cancellation')
// 9. Test /api/sessions/:sessionId/messages (GET) with tool formatting
// Let's write some message content to the JSONL file first
fs.appendFileSync(
sessionFile,
JSON.stringify({
type: 'message',
id: 'msg-user-1',
message: { role: 'user', content: [{ type: 'text', text: 'run command' }], timestamp: Date.now() },
}) + '\n',
)
fs.appendFileSync(
sessionFile,
JSON.stringify({
type: 'message',
id: 'msg-assistant-1',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'sure' },
{ type: 'toolCall', id: 'tool-call-123', name: 'run_command', arguments: { command: 'echo hello' } },
{ type: 'toolCall', id: 'tool-call-456', name: 'run_command', arguments: { command: 'echo chained' } },
{ type: 'toolCall', id: 'tool-call-789', name: 'run_command', arguments: { command: 'echo error' } },
],
timestamp: Date.now(),
},
}) + '\n',
)
fs.appendFileSync(
sessionFile,
JSON.stringify({
type: 'message',
id: 'tool-result-123',
parentId: 'msg-assistant-1',
message: {
role: 'toolResult',
toolCallId: 'tool-call-123',
toolName: 'run_command',
content: 'hello',
isError: false,
},
}) + '\n',
)
fs.appendFileSync(
sessionFile,
JSON.stringify({
type: 'message',
id: 'tool-result-456',
parentId: 'tool-result-123',
message: {
role: 'toolResult',
toolCallId: 'tool-call-456',
toolName: 'run_command',
content: [{ type: 'text', text: 'chained output' }],
isError: false,
},
}) + '\n',
)
fs.appendFileSync(
sessionFile,
JSON.stringify({
type: 'message',
id: 'tool-result-789',
parentId: 'tool-result-456',
message: {
role: 'toolResult',
toolCallId: 'tool-call-789',
toolName: 'run_command',
content: [{ type: 'text', text: 'chained failure' }],
isError: true,
},
}) + '\n',
)
const messagesRes = await fetch(`${baseUrl}/api/sessions/${activeSessionId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(messagesRes.status).toBe(200)
const messagesData = (await messagesRes.json()) as any
expect(messagesData.messages).toBeDefined()
expect(messagesData.messages.length).toBe(2)
expect(messagesData.messages.every((message: any) => message.info.role !== 'toolResult')).toBe(true)
// Verify tool part mapping has both id and callID and chained toolResult entries attach by toolCallId.
const assistantMsg = messagesData.messages[1]
const toolsById = new Map(assistantMsg.parts.filter((p: any) => p.type === 'tool').map((p: any) => [p.id, p]))
const toolPart = toolsById.get('tool-call-123') as any
expect(toolPart).toBeDefined()
expect(toolPart.id).toBe('tool-call-123')
expect(toolPart.callID).toBe('tool-call-123')
expect(toolPart.state.status).toBe('completed')
expect(toolPart.state.output).toBe('hello')
const chainedToolPart = toolsById.get('tool-call-456') as any
expect(chainedToolPart.state.status).toBe('completed')
expect(chainedToolPart.state.output).toBe('chained output')
const failedChainedToolPart = toolsById.get('tool-call-789') as any
expect(failedChainedToolPart.state.status).toBe('error')
expect(failedChainedToolPart.state.output).toBe('chained failure')
expect(failedChainedToolPart.state.error).toBe('chained failure')
// 10. Test SSE Event Stream (/global/event)
const eventsRes = await fetch(`${baseUrl}/global/event`, {
headers: { Authorization: `Bearer ${token}` },
})
expect(eventsRes.status).toBe(200)
const reader = eventsRes.body?.getReader()
expect(reader).toBeDefined()
// Read initial server.connected event
const chunk1 = await reader?.read()
const text1 = new TextDecoder().decode(chunk1?.value)
expect(text1).toContain('server.connected')
// Trigger new session creation
const nextSessionId = 'test-session-uuid-56789'
const nextSessionFile = path.join(tempSessionDir, `session_${nextSessionId}.jsonl`)
fs.writeFileSync(
nextSessionFile,
JSON.stringify({
type: 'session',
id: nextSessionId,
timestamp: new Date().toISOString(),
cwd: '/mock/cwd',
}) + '\n',
)
const nextMockContext = {
...mockContext,
sessionManager: {
getSessionDir: () => tempSessionDir,
getSessionId: () => nextSessionId,
},
}
// Trigger session_start for the new session - should broadcast session.created!
await listeners['session_start']({}, nextMockContext as any)
const chunk2 = await reader?.read()
const text2 = new TextDecoder().decode(chunk2?.value)
expect(text2).toContain('session.created')
expect(text2).toContain(nextSessionId)
// Trigger agent_start - should broadcast session.status and session.updated!
await listeners['agent_start']()
// The two events are separate writes, so TCP may deliver them in different chunks.
let text3 = ''
while (!text3.includes('session.updated')) {
const chunk = await reader?.read()
if (!chunk || chunk.done) break
text3 += new TextDecoder().decode(chunk.value)
}
expect(text3).toContain('session.status')
expect(text3).toContain('working')
expect(text3).toContain('session.updated')
// Close reader stream
await reader?.cancel()
// Trigger session_shutdown
await listeners['session_shutdown']({})
// Verify discovery file is deleted
expect(fs.existsSync(discoveryPath)).toBe(false)
})
})