2026-06-14 16:05:04 +01:00
|
|
|
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
|
|
|
|
|
2026-06-15 20:58:07 +01:00
|
|
|
import type { QueryAgentToolbox } from "./query-agent-toolbox.ts"
|
2026-06-14 16:05:04 +01:00
|
|
|
import type { QueryAgentEvent } from "./query-agent.ts"
|
|
|
|
|
|
|
|
|
|
interface FakePiSession {
|
|
|
|
|
subscribe(listener: (event: unknown) => void): () => void
|
|
|
|
|
prompt(message: string): Promise<void>
|
|
|
|
|
dispose(): void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let createAgentSessionCalls = 0
|
|
|
|
|
let createAgentSessionOptions: unknown
|
2026-06-15 20:58:07 +01:00
|
|
|
let runtimeApiKeyCalls: Array<{ provider: string; apiKey: string }> = []
|
|
|
|
|
let modelFindCalls: Array<{ provider: string; modelId: string }> = []
|
2026-06-14 16:05:04 +01:00
|
|
|
let promptCalls = 0
|
|
|
|
|
let unsubscribeCalls = 0
|
|
|
|
|
let sessionListeners: Array<(event: unknown) => void> = []
|
|
|
|
|
let promptEvents: unknown[] = []
|
|
|
|
|
|
|
|
|
|
let sessionCreationStarted: Promise<void>
|
|
|
|
|
let resolveSessionCreationStarted: () => void
|
|
|
|
|
let sessionCreationReleased: Promise<void>
|
|
|
|
|
let releaseSessionCreation: () => void
|
|
|
|
|
let promptStarted: Promise<void>
|
|
|
|
|
let resolvePromptStarted: () => void
|
|
|
|
|
let promptReleased: Promise<void>
|
|
|
|
|
let releasePrompt: () => void
|
|
|
|
|
|
|
|
|
|
const fakeSession: FakePiSession = {
|
|
|
|
|
subscribe(listener: (event: unknown) => void): () => void {
|
|
|
|
|
sessionListeners.push(listener)
|
|
|
|
|
return () => {
|
|
|
|
|
const index = sessionListeners.indexOf(listener)
|
|
|
|
|
if (index >= 0) {
|
|
|
|
|
sessionListeners.splice(index, 1)
|
|
|
|
|
}
|
|
|
|
|
unsubscribeCalls += 1
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async prompt(_message: string): Promise<void> {
|
|
|
|
|
promptCalls += 1
|
|
|
|
|
resolvePromptStarted()
|
|
|
|
|
await promptReleased
|
|
|
|
|
for (const event of promptEvents) {
|
|
|
|
|
for (const listener of sessionListeners) {
|
|
|
|
|
listener(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
dispose(): void {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock.module("@earendil-works/pi-coding-agent", () => ({
|
|
|
|
|
AuthStorage: {
|
|
|
|
|
inMemory() {
|
|
|
|
|
return {
|
2026-06-15 20:58:07 +01:00
|
|
|
setRuntimeApiKey(provider: string, apiKey: string): void {
|
|
|
|
|
runtimeApiKeyCalls.push({ provider, apiKey })
|
|
|
|
|
},
|
2026-06-14 16:05:04 +01:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async createAgentSession(options: unknown) {
|
|
|
|
|
createAgentSessionCalls += 1
|
|
|
|
|
createAgentSessionOptions = options
|
|
|
|
|
resolveSessionCreationStarted()
|
|
|
|
|
await sessionCreationReleased
|
|
|
|
|
return { session: fakeSession }
|
|
|
|
|
},
|
|
|
|
|
createExtensionRuntime() {
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
defineTool(tool: unknown): unknown {
|
|
|
|
|
return tool
|
|
|
|
|
},
|
|
|
|
|
ModelRegistry: {
|
|
|
|
|
inMemory(_authStorage: unknown) {
|
|
|
|
|
return {
|
2026-06-15 20:58:07 +01:00
|
|
|
find(provider: string, modelId: string): unknown {
|
|
|
|
|
modelFindCalls.push({ provider, modelId })
|
2026-06-14 16:05:04 +01:00
|
|
|
return { id: "mock-model" }
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
SessionManager: {
|
|
|
|
|
inMemory(_cwd: string): unknown {
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
SettingsManager: {
|
|
|
|
|
inMemory(_settings: unknown): unknown {
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
createAgentSessionCalls = 0
|
|
|
|
|
createAgentSessionOptions = undefined
|
2026-06-15 20:58:07 +01:00
|
|
|
runtimeApiKeyCalls = []
|
|
|
|
|
modelFindCalls = []
|
2026-06-14 16:05:04 +01:00
|
|
|
promptCalls = 0
|
|
|
|
|
unsubscribeCalls = 0
|
|
|
|
|
sessionListeners = []
|
|
|
|
|
promptEvents = []
|
|
|
|
|
|
|
|
|
|
resolveSessionCreationStarted = () => {}
|
|
|
|
|
sessionCreationStarted = new Promise((resolve) => {
|
|
|
|
|
resolveSessionCreationStarted = resolve
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
releaseSessionCreation = () => {}
|
|
|
|
|
sessionCreationReleased = new Promise((resolve) => {
|
|
|
|
|
releaseSessionCreation = resolve
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
resolvePromptStarted = () => {}
|
|
|
|
|
promptStarted = new Promise((resolve) => {
|
|
|
|
|
resolvePromptStarted = resolve
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
releasePrompt = () => {}
|
|
|
|
|
promptReleased = new Promise((resolve) => {
|
|
|
|
|
releasePrompt = resolve
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("PiQueryAgent", () => {
|
|
|
|
|
test("rejects a concurrent first query while the Pi session is being created", async () => {
|
|
|
|
|
const { PiQueryAgent } = await import("./pi-query-agent.ts")
|
|
|
|
|
const agent = new PiQueryAgent({
|
2026-06-15 20:58:07 +01:00
|
|
|
userId: "user-1",
|
|
|
|
|
toolbox: createStubToolbox(),
|
|
|
|
|
apiKey: "test-api-key",
|
2026-06-14 16:05:04 +01:00
|
|
|
cwd: "/tmp/freya-pi-query-agent-test",
|
|
|
|
|
systemPrompt: "test",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const firstEvents = collectEvents(
|
|
|
|
|
agent.ask({
|
|
|
|
|
message: "first",
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sessionCreationStarted
|
|
|
|
|
|
|
|
|
|
const secondEvents = await collectEvents(
|
|
|
|
|
agent.ask({
|
|
|
|
|
message: "second",
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
expect(secondEvents).toEqual([
|
|
|
|
|
{
|
|
|
|
|
type: "error",
|
|
|
|
|
message: "A query is already running for this user",
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
expect(createAgentSessionCalls).toBe(1)
|
2026-06-15 20:58:07 +01:00
|
|
|
expect(runtimeApiKeyCalls).toEqual([{ provider: "openrouter", apiKey: "test-api-key" }])
|
|
|
|
|
expect(modelFindCalls).toEqual([{ provider: "openrouter", modelId: "z-ai/glm-4.7-flash" }])
|
2026-06-14 16:05:04 +01:00
|
|
|
expect(promptCalls).toBe(0)
|
|
|
|
|
|
|
|
|
|
releaseSessionCreation()
|
|
|
|
|
await promptStarted
|
|
|
|
|
releasePrompt()
|
|
|
|
|
|
|
|
|
|
expect(await firstEvents).toEqual([{ type: "done" }])
|
|
|
|
|
expect(promptCalls).toBe(1)
|
|
|
|
|
expect(unsubscribeCalls).toBe(1)
|
|
|
|
|
if (!isRecord(createAgentSessionOptions)) {
|
|
|
|
|
throw new Error("createAgentSession options were not captured")
|
|
|
|
|
}
|
|
|
|
|
expect("agentDir" in createAgentSessionOptions).toBe(false)
|
|
|
|
|
expect(createAgentSessionOptions.resourceLoader).toBeDefined()
|
|
|
|
|
|
|
|
|
|
agent.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("surfaces Pi message_end provider errors instead of done", async () => {
|
|
|
|
|
const { PiQueryAgent } = await import("./pi-query-agent.ts")
|
|
|
|
|
const agent = new PiQueryAgent({
|
2026-06-15 20:58:07 +01:00
|
|
|
userId: "user-1",
|
|
|
|
|
toolbox: createStubToolbox(),
|
2026-06-14 16:05:04 +01:00
|
|
|
cwd: "/tmp/freya-pi-query-agent-test",
|
|
|
|
|
systemPrompt: "test",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
promptEvents = [
|
|
|
|
|
{
|
|
|
|
|
type: "message_end",
|
|
|
|
|
message: {
|
|
|
|
|
role: "assistant",
|
|
|
|
|
stopReason: "error",
|
|
|
|
|
errorMessage: "Rate limit exceeded",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const events = collectEvents(
|
|
|
|
|
agent.ask({
|
|
|
|
|
message: "hello",
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sessionCreationStarted
|
|
|
|
|
releaseSessionCreation()
|
|
|
|
|
await promptStarted
|
|
|
|
|
releasePrompt()
|
|
|
|
|
|
|
|
|
|
expect(await events).toEqual([{ type: "error", message: "Rate limit exceeded" }])
|
|
|
|
|
expect(unsubscribeCalls).toBe(1)
|
|
|
|
|
|
|
|
|
|
agent.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("surfaces Pi agent_end provider errors instead of done", async () => {
|
|
|
|
|
const { PiQueryAgent } = await import("./pi-query-agent.ts")
|
|
|
|
|
const agent = new PiQueryAgent({
|
2026-06-15 20:58:07 +01:00
|
|
|
userId: "user-1",
|
|
|
|
|
toolbox: createStubToolbox(),
|
2026-06-14 16:05:04 +01:00
|
|
|
cwd: "/tmp/freya-pi-query-agent-test",
|
|
|
|
|
systemPrompt: "test",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
promptEvents = [
|
|
|
|
|
{
|
|
|
|
|
type: "agent_end",
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: "assistant",
|
|
|
|
|
stopReason: "error",
|
|
|
|
|
errorMessage: "Invalid API key",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const events = collectEvents(
|
|
|
|
|
agent.ask({
|
|
|
|
|
message: "hello",
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await sessionCreationStarted
|
|
|
|
|
releaseSessionCreation()
|
|
|
|
|
await promptStarted
|
|
|
|
|
releasePrompt()
|
|
|
|
|
|
|
|
|
|
expect(await events).toEqual([{ type: "error", message: "Invalid API key" }])
|
|
|
|
|
expect(unsubscribeCalls).toBe(1)
|
|
|
|
|
|
|
|
|
|
agent.dispose()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function collectEvents(events: AsyncIterable<QueryAgentEvent>): Promise<QueryAgentEvent[]> {
|
|
|
|
|
const result: QueryAgentEvent[] = []
|
|
|
|
|
for await (const event of events) {
|
|
|
|
|
result.push(event)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 20:58:07 +01:00
|
|
|
function createStubToolbox(): QueryAgentToolbox {
|
2026-06-14 16:05:04 +01:00
|
|
|
return {
|
2026-06-15 20:58:07 +01:00
|
|
|
async listSources(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
async getContext(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
async getFeedItem(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
async queryContext(): Promise<never> {
|
2026-06-14 16:05:04 +01:00
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
2026-06-15 20:58:07 +01:00
|
|
|
async listContext(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
async getSourceData(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
async executeAction(): Promise<never> {
|
|
|
|
|
throw new Error("not used")
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-06-14 16:05:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return typeof value === "object" && value !== null
|
|
|
|
|
}
|