import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NextRequest } from 'next/server'; import { convertToLlm } from '@earendil-works/pi-agent-core'; const PI_CHAT_FLAG = 'NEXT_PUBLIC_PI_CHAT_ENABLED'; let originalPiChatFlag: string | undefined; type MockTool = { name: string; execute: ( toolCallId: string, args: Record, signal?: AbortSignal, ) => Promise | unknown; }; type MockAgentOptions = { systemPrompt: string; tools: MockTool[]; convertToLlm?: unknown; afterToolCall?: ( context: unknown, ) => | { terminate?: boolean; isError?: boolean } | undefined | Promise<{ terminate?: boolean; isError?: boolean } | undefined>; }; const mocks = vi.hoisted(() => ({ resolveModel: vi.fn(), buildAgent: vi.fn(), createCallLlmStreamFn: vi.fn(), logError: vi.fn(), })); vi.mock('@/lib/server/resolve-model', () => ({ resolveModel: mocks.resolveModel, })); vi.mock('@/lib/ai/providers', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isProviderKeyRequired: vi.fn(() => false), }; }); vi.mock('@/lib/live-mode', () => ({ isLiveMode: false, })); vi.mock('@/lib/agent/runtime/stream-fn', () => ({ createCallLlmStreamFn: mocks.createCallLlmStreamFn, })); vi.mock('@/lib/agent/runtime/build-agent', () => ({ buildAgent: mocks.buildAgent, })); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: mocks.logError, debug: vi.fn(), }), })); function makeRequest(body: Record): NextRequest { return new Request('http://localhost/api/chat/pi', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) as unknown as NextRequest; } function makeBody() { return { messages: [ { id: 'user-1', role: 'user', parts: [{ type: 'text', text: 'Explain city cooling.' }], }, ], storeState: { stage: { id: 'stage-1', name: 'City Cooling', whiteboard: [] }, scenes: [ { id: 'scene-1', title: 'Cooling', type: 'slide', content: { type: 'slide', canvas: { elements: [] } }, }, ], currentSceneId: 'scene-1', mode: 'autonomous', whiteboardOpen: false, }, config: { agentIds: ['default-1'], agentConfigs: [ { id: 'default-1', name: 'Teacher', role: 'teacher', persona: 'You teach clearly.', priority: 10, avatar: '', color: '#3366ff', allowedActions: [], }, ], }, apiKey: '', model: 'test:model', }; } function makeAgentConfig(opts: { id: string; name: string; role: string }) { return { id: opts.id, name: opts.name, role: opts.role, persona: `${opts.name} speaks briefly.`, priority: opts.role === 'teacher' ? 10 : 5, avatar: '', color: '#3366ff', allowedActions: [], }; } async function readSseEvents(response: Response) { const text = await response.text(); return text .split('\n\n') .filter((part) => part.startsWith('data: ')) .map((part) => JSON.parse(part.slice('data: '.length))); } function expectCueUserBeforeDone(events: Array<{ type: string }>) { const cueUserIndex = events.findIndex((event) => event.type === 'cue_user'); const doneIndex = events.findIndex((event) => event.type === 'done'); expect(cueUserIndex).toBeGreaterThanOrEqual(0); expect(doneIndex).toBeGreaterThanOrEqual(0); expect(cueUserIndex).toBeLessThan(doneIndex); } function mockDirectorWithAgentTurn(opts: { explicitlyCueUser: boolean; closeAfterCue?: boolean }) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); const closeSession = agentOpts.tools.find((tool) => tool.name === 'close_session'); await callAgent?.execute('call-1', { agentId: 'default-1', instruction: 'Give one concise answer.', }); if (opts.explicitlyCueUser) { await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); } if (opts.closeAfterCue) { await closeSession?.execute('close-1', { endReason: 'user_done' }); } }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async () => {}, waitForIdle: async () => {}, state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'Cool roofs help.' }], }, ], }, }; }); } function mockDirectorWebEvidenceLifecycle(captured: { childPrompts: string[]; callAgentResults: Array>; }) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const webSearch = agentOpts.tools.find((tool) => tool.name === 'web_search'); const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await webSearch?.execute('search-1', { query: 'supported current fact' }); captured.callAgentResults.push( (await callAgent?.execute('call-1', { agentId: 'default-1', instruction: 'Explain the supported current fact with its source.', })) as Record, ); captured.callAgentResults.push( (await callAgent?.execute('call-2', { agentId: 'default-1', instruction: 'Add one concise clarification using the same evidence.', })) as Record, ); await webSearch?.execute('search-2', { query: 'unsupported current fact' }); captured.callAgentResults.push( (await callAgent?.execute('call-3', { agentId: 'default-1', instruction: 'State that the second fact lacks evidence.', })) as Record, ); await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async (prompt: string) => { captured.childPrompts.push(prompt); }, waitForIdle: async () => {}, state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'Evidence-aware classroom reply.' }], }, ], }, }; }); } function mockDirectorWebEvidenceChildFailure(captured: { childPrompts: string[]; callAgentResults: Array>; }) { let childBuildIndex = 0; mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const webSearch = agentOpts.tools.find((tool) => tool.name === 'web_search'); const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await webSearch?.execute('search-1', { query: 'supported current fact' }); captured.callAgentResults.push( (await callAgent?.execute('call-1', { agentId: 'default-1', instruction: 'Use the current evidence, then fail.', })) as Record, ); captured.callAgentResults.push( (await callAgent?.execute('call-2', { agentId: 'default-1', instruction: 'Recover without reusing evidence from the failed child.', })) as Record, ); await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } const childIndex = childBuildIndex; childBuildIndex += 1; return { subscribe: () => () => {}, prompt: async (prompt: string) => { captured.childPrompts.push(prompt); if (childIndex === 0) throw new Error('simulated child failure'); }, waitForIdle: async () => {}, state: { messages: childIndex === 0 ? [] : [ { role: 'assistant', content: [{ type: 'text', text: 'Recovered without stale evidence.' }], }, ], }, }; }); } function mockDirectorReadSceneDelegation(captured: { childPrompts: string[]; callAgentResults: Array>; }) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const readScene = agentOpts.tools.find((tool) => tool.name === 'read_scene'); const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await readScene?.execute('read-1', { sceneId: 'scene-1', }); captured.callAgentResults.push( (await callAgent?.execute('call-1', { agentId: 'default-1', instruction: 'Explain the relevant course fact.', })) as Record, ); captured.callAgentResults.push( (await callAgent?.execute('call-2', { agentId: 'default-1', instruction: 'Add a clarification without reusing prior scene evidence.', })) as Record, ); await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async (prompt: string) => captured.childPrompts.push(prompt), waitForIdle: async () => {}, state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'Scene-grounded classroom reply.' }], }, ], }, }; }); } // Director calls the teacher once; the teacher child streams a structured-output // array carrying a real whiteboard action, so this turn's ledger is non-empty. function mockDirectorWithWhiteboardTeacherTurn(actionJson: string) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await callAgent?.execute('call-1', { agentId: 'default-1', instruction: 'Draw the key point on the whiteboard.', }); await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } let handler: ((event: unknown) => unknown) | null = null; return { subscribe: (h: (event: unknown) => unknown) => { handler = h; return () => {}; }, prompt: async () => { await handler?.({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: actionJson }, }); }, waitForIdle: async () => {}, state: { messages: [] }, }; }); } function mockDirectorWithTwoTeacherTurns() { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await callAgent?.execute('normal-1', { agentId: 'default-1', instruction: 'Give a normal answer.', }); await callAgent?.execute('normal-2-over-local-limit', { agentId: 'default-1', instruction: 'This second normal turn should be skipped locally.', }); await cueUser?.execute('cue-1', { prompt: 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async () => {}, waitForIdle: async () => {}, state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: 'Fresh answer.' }], }, ], }, }; }); } function mockDirectorWithAgentThenCue(opts: { agentId: string; childText: string; cuePrompt?: string; }) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); const cueUser = agentOpts.tools.find((tool) => tool.name === 'cue_user'); await callAgent?.execute('call-1', { agentId: opts.agentId, instruction: 'Give one concise answer.', }); await cueUser?.execute('cue-1', { prompt: opts.cuePrompt ?? 'Any follow-up?' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async () => {}, waitForIdle: async () => {}, state: { messages: [ { role: 'assistant', content: [{ type: 'text', text: opts.childText }], }, ], }, }; }); } function mockDirectorCloseSessionWithoutTeacherTurn() { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'close_session'); if (isDirector) { return { prompt: async () => { const closeSession = agentOpts.tools.find((tool) => tool.name === 'close_session'); await closeSession?.execute('close-1', { endReason: 'user_done' }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async () => {}, waitForIdle: async () => {}, state: { messages: [] }, }; }); } function mockDirectorWithRejectedCalls(counter: { value: number }) { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => ({ prompt: async () => { const callAgent = agentOpts.tools.find((tool) => tool.name === 'call_agent'); for (let i = 0; i < 20; i += 1) { const result = await callAgent?.execute(`invalid-${i}`, { agentId: 'missing-agent', instruction: 'Please answer.', }); counter.value += 1; const guard = await agentOpts.afterToolCall?.({ toolCall: { name: 'call_agent' }, result, }); if (guard?.terminate) break; } }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, })); } function mockDirectorWithFailedSceneRead() { mocks.buildAgent.mockImplementation((agentOpts: MockAgentOptions) => { const isDirector = agentOpts.tools.some((tool) => tool.name === 'cue_user'); if (isDirector) { return { prompt: async () => { const readScene = agentOpts.tools.find((tool) => tool.name === 'read_scene'); const result = await readScene?.execute('read-missing', { sceneId: 'missing-scene', }); await agentOpts.afterToolCall?.({ toolCall: { name: 'read_scene' }, args: { sceneId: 'missing-scene' }, result, isError: false, }); }, waitForIdle: async () => {}, subscribe: () => () => {}, state: { messages: [] }, }; } return { subscribe: () => () => {}, prompt: async () => {}, waitForIdle: async () => {}, state: { messages: [] }, }; }); } describe('POST /api/chat/pi cue_user', () => { beforeEach(() => { originalPiChatFlag = process.env[PI_CHAT_FLAG]; process.env[PI_CHAT_FLAG] = 'true'; vi.resetModules(); mocks.resolveModel.mockReset(); mocks.buildAgent.mockReset(); mocks.createCallLlmStreamFn.mockReset(); mocks.logError.mockReset(); mocks.resolveModel.mockResolvedValue({ model: { id: 'language-model' }, apiKey: 'resolved-key', providerId: 'test-provider', modelInfo: { outputWindow: 4096 }, thinkingConfig: { mode: 'disabled', enabled: false }, }); mocks.createCallLlmStreamFn.mockReturnValue(vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); if (originalPiChatFlag === undefined) { delete process.env[PI_CHAT_FLAG]; } else { process.env[PI_CHAT_FLAG] = originalPiChatFlag; } }); it('wires Pi standard message conversion only into the Director agent', async () => { mockDirectorWithAgentTurn({ explicitlyCueUser: false }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); await readSseEvents(response); const agentOptions = mocks.buildAgent.mock.calls.map( ([options]) => options as MockAgentOptions, ); const directorOptions = agentOptions.find((options) => options.tools.some((tool) => tool.name === 'cue_user'), ); const childOptions = agentOptions.filter((options) => options !== directorOptions); expect(response.status).toBe(200); expect(directorOptions?.convertToLlm).toBe(convertToLlm); expect(childOptions.every((options) => options.convertToLlm === undefined)).toBe(true); }); it('does not expose web_search when the server feature flag is disabled', async () => { const originalWebSearchFlag = process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; delete process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; try { mockDirectorWithAgentTurn({ explicitlyCueUser: false }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); await readSseEvents(response); const directorOptions = mocks.buildAgent.mock.calls .map(([options]) => options as MockAgentOptions) .find((options) => options.tools.some((tool) => tool.name === 'cue_user')); expect(response.status).toBe(200); expect(directorOptions?.tools.some((tool) => tool.name === 'web_search')).toBe(false); expect(directorOptions?.systemPrompt).not.toContain('# External Web Evidence'); expect(directorOptions?.systemPrompt).not.toContain('Call `web_search` before `call_agent`'); } finally { if (originalWebSearchFlag === undefined) delete process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; else process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH = originalWebSearchFlag; } }); it('hands successful web evidence to one child and clears it before later delegations', async () => { const originalWebSearchFlag = process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; const originalKey = process.env.RESPONSES_WEB_SEARCH_API_KEY; const originalBaseUrl = process.env.RESPONSES_WEB_SEARCH_BASE_URL; const originalModel = process.env.RESPONSES_WEB_SEARCH_MODEL; process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH = 'true'; process.env.RESPONSES_WEB_SEARCH_API_KEY = 'test-key'; process.env.RESPONSES_WEB_SEARCH_BASE_URL = 'https://responses-proxy.test/v1'; process.env.RESPONSES_WEB_SEARCH_MODEL = 'search-model'; const captured = { childPrompts: [] as string[], callAgentResults: [] as Array>, }; mockDirectorWebEvidenceLifecycle(captured); const fetchMock = vi .fn() .mockResolvedValueOnce( new Response( JSON.stringify({ status: 'completed', output: [ { type: 'web_search_call', status: 'completed' }, { type: 'message', content: [ { type: 'output_text', text: 'Supported result.', annotations: [ { title: 'Official source', url: 'https://example.test/official' }, ], }, ], }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ status: 'completed', output: [ { type: 'web_search_call', status: 'completed' }, { type: 'message', content: [{ type: 'output_text', text: 'Unsupported result.', annotations: [] }], }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ); vi.stubGlobal('fetch', fetchMock); try { const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(captured.childPrompts).toHaveLength(3); expect(captured.childPrompts[0]).toContain('https://example.test/official'); expect(captured.childPrompts[1]).not.toContain('https://example.test/official'); expect(captured.childPrompts[2]).not.toContain('https://example.test/official'); expect(captured.callAgentResults[0]?.details).toMatchObject({ webEvidence: { query: 'supported current fact', sourceCount: 1, }, }); expect(captured.callAgentResults[1]?.details).not.toHaveProperty('webEvidence'); expect(captured.callAgentResults[2]?.details).not.toHaveProperty('webEvidence'); expect(doneEvent?.data.totalAgents).toBe(3); } finally { if (originalWebSearchFlag === undefined) delete process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; else process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH = originalWebSearchFlag; if (originalKey === undefined) delete process.env.RESPONSES_WEB_SEARCH_API_KEY; else process.env.RESPONSES_WEB_SEARCH_API_KEY = originalKey; if (originalBaseUrl === undefined) delete process.env.RESPONSES_WEB_SEARCH_BASE_URL; else process.env.RESPONSES_WEB_SEARCH_BASE_URL = originalBaseUrl; if (originalModel === undefined) delete process.env.RESPONSES_WEB_SEARCH_MODEL; else process.env.RESPONSES_WEB_SEARCH_MODEL = originalModel; } }); it('does not leak consumed web evidence after the selected child fails', async () => { const originalWebSearchFlag = process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; const originalKey = process.env.RESPONSES_WEB_SEARCH_API_KEY; const originalBaseUrl = process.env.RESPONSES_WEB_SEARCH_BASE_URL; const originalModel = process.env.RESPONSES_WEB_SEARCH_MODEL; process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH = 'true'; process.env.RESPONSES_WEB_SEARCH_API_KEY = 'test-key'; process.env.RESPONSES_WEB_SEARCH_BASE_URL = 'https://responses-proxy.test/v1'; process.env.RESPONSES_WEB_SEARCH_MODEL = 'search-model'; const captured = { childPrompts: [] as string[], callAgentResults: [] as Array>, }; mockDirectorWebEvidenceChildFailure(captured); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( new Response( JSON.stringify({ status: 'completed', output: [ { type: 'web_search_call', status: 'completed' }, { type: 'message', content: [ { type: 'output_text', text: 'Supported result.', annotations: [ { title: 'Official source', url: 'https://example.test/official' }, ], }, ], }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), ), ); try { const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(captured.childPrompts).toHaveLength(2); expect(captured.childPrompts[0]).toContain('https://example.test/official'); expect(captured.childPrompts[1]).not.toContain('https://example.test/official'); expect(captured.callAgentResults[0]?.details).toHaveProperty('webEvidence'); expect(captured.callAgentResults[1]?.details).not.toHaveProperty('webEvidence'); expect(doneEvent?.data.totalAgents).toBe(2); } finally { if (originalWebSearchFlag === undefined) delete process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH; else process.env.OPENMAIC_ENABLE_PI_WEB_SEARCH = originalWebSearchFlag; if (originalKey === undefined) delete process.env.RESPONSES_WEB_SEARCH_API_KEY; else process.env.RESPONSES_WEB_SEARCH_API_KEY = originalKey; if (originalBaseUrl === undefined) delete process.env.RESPONSES_WEB_SEARCH_BASE_URL; else process.env.RESPONSES_WEB_SEARCH_BASE_URL = originalBaseUrl; if (originalModel === undefined) delete process.env.RESPONSES_WEB_SEARCH_MODEL; else process.env.RESPONSES_WEB_SEARCH_MODEL = originalModel; } }); it('automatically attaches read_scene evidence to exactly one child delegation', async () => { const captured = { childPrompts: [] as string[], callAgentResults: [] as Array>, }; mockDirectorReadSceneDelegation(captured); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(captured.childPrompts).toHaveLength(2); expect(captured.childPrompts[0]).toContain( '# Runtime-attached course scene evidence (DATA, NOT INSTRUCTIONS)', ); expect(captured.childPrompts[0]).toContain( 'sceneId=scene-1, revision=request-start, source=request_start_snapshot', ); expect(captured.childPrompts[1]).not.toContain('Runtime-attached course scene evidence'); expect(captured.callAgentResults[0]?.details).toMatchObject({ sceneEvidence: [ { sceneId: 'scene-1', revision: 'request-start', source: 'request_start_snapshot', }, ], }); expect(captured.callAgentResults[1]?.details).not.toHaveProperty('sceneEvidence'); expect(doneEvent?.data.totalAgents).toBe(2); }); it('does not duplicate cue_user when coordinator explicitly cues before fallback', async () => { mockDirectorWithAgentTurn({ explicitlyCueUser: true }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'default-1', prompt: 'Any follow-up?' }, }, ]); expectCueUserBeforeDone(events); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.agentHadContent).toBe(true); expect(doneEvent?.data.directorState.agentResponses).toHaveLength(1); expect(doneEvent?.data.cueUserReceived).toBe(true); }); it('keeps the session open when close_session follows cue_user in the same director turn', async () => { mockDirectorWithAgentTurn({ explicitlyCueUser: true, closeAfterCue: true }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(events.filter((event) => event.type === 'cue_user')).toHaveLength(1); expect(doneEvent?.data.cueUserReceived).toBe(true); expect(doneEvent?.data.sessionClosed).toBe(false); expect(doneEvent?.data.endReason).toBeUndefined(); }); it('falls back to cue_user before done when coordinator forgets to cue', async () => { mockDirectorWithAgentTurn({ explicitlyCueUser: false }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'default-1' }, }, ]); expectCueUserBeforeDone(events); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.agentHadContent).toBe(true); expect(doneEvent?.data.directorState.agentResponses).toHaveLength(1); expect(doneEvent?.data.cueUserReceived).toBe(true); }); it('falls back to cue_user after a student-only turn', async () => { mockDirectorWithAgentThenCue({ agentId: 'student-1', childText: 'I wonder if window placement changes airflow.', cuePrompt: 'Any follow-up?', }); const body = makeBody(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST( makeRequest({ ...body, config: { ...body.config, agentIds: ['student-1'], agentConfigs: [makeAgentConfig({ id: 'student-1', name: 'Student', role: 'student' })], }, }), ); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'student-1' }, }, ]); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.directorState.agentResponses).toEqual([ expect.objectContaining({ agentId: 'student-1', contentPreview: 'I wonder if window placement changes airflow.', }), ]); expect(doneEvent?.data.cueUserReceived).toBe(true); }); it('allows explicit cue_user after a teacher substantive turn', async () => { mockDirectorWithAgentThenCue({ agentId: 'default-1', childText: 'Cool roofs reduce heat by reflecting more sunlight.', cuePrompt: 'Any follow-up?', }); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'default-1', prompt: 'Any follow-up?' }, }, ]); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.cueUserReceived).toBe(true); }); it('allows explicit cue_user after a teaching assistant fallback substantive turn', async () => { mockDirectorWithAgentThenCue({ agentId: 'assistant-1', childText: 'A cool roof reflects more sunlight, so the surface absorbs less heat.', cuePrompt: 'Any follow-up?', }); const body = makeBody(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST( makeRequest({ ...body, config: { ...body.config, agentIds: ['assistant-1'], agentConfigs: [ makeAgentConfig({ id: 'assistant-1', name: 'Assistant', role: 'assistant' }), ], }, }), ); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'assistant-1', prompt: 'Any follow-up?' }, }, ]); expectCueUserBeforeDone(events); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.directorState.agentResponses).toEqual([ expect.objectContaining({ agentId: 'assistant-1', contentPreview: 'A cool roof reflects more sunlight, so the surface absorbs less heat.', }), ]); expect(doneEvent?.data.cueUserReceived).toBe(true); }); it('rejects close_session when the current turn has no visible agent response', async () => { mockDirectorCloseSessionWithoutTeacherTurn(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(cueUserEvents).toEqual([]); expect(doneEvent?.data.sessionClosed).toBe(false); expect(doneEvent?.data.endReason).toBeUndefined(); expect(doneEvent?.data.cueUserReceived).toBe(false); }); it('terminates the director after the hard tool-call budget', async () => { const counter = { value: 0 }; mockDirectorWithRejectedCalls(counter); const body = makeBody(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST( makeRequest({ ...body, config: { ...body.config, piMaxAgentTurns: 1 }, }), ); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(counter.value).toBe(4); expect(doneEvent?.data.totalAgents).toBe(0); expect(doneEvent?.data.agentHadContent).toBe(false); }); it('marks failed evidence tools as native tool errors and exposes an audit trace', async () => { mockDirectorWithFailedSceneRead(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST(makeRequest(makeBody())); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(doneEvent?.data.directorToolTrace).toEqual([ expect.objectContaining({ sequence: 1, toolName: 'read_scene', args: { sceneId: 'missing-scene' }, isError: true, details: expect.objectContaining({ status: 'not_found', sceneId: 'missing-scene', }), }), ]); }); it('uses only this loop turn count for the classroom agent turn cap', async () => { mockDirectorWithTwoTeacherTurns(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST( makeRequest({ ...makeBody(), config: { ...makeBody().config, piMaxAgentTurns: 1, }, directorState: { turnCount: 6, agentResponses: [ { agentId: 'student-1', agentName: 'Student', contentPreview: 'I have one thought.', actionCount: 0, whiteboardActions: [], }, ], whiteboardLedger: [], }, }), ); const events = await readSseEvents(response); const agentStarts = events.filter((event) => event.type === 'agent_start'); const cueUserEvents = events.filter((event) => event.type === 'cue_user'); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); expect(agentStarts).toEqual([ { type: 'agent_start', data: expect.objectContaining({ agentId: 'default-1', agentName: 'Teacher' }), }, ]); expect(cueUserEvents).toEqual([ { type: 'cue_user', data: { fromAgentId: 'default-1', prompt: 'Any follow-up?' }, }, ]); expectCueUserBeforeDone(events); expect(doneEvent?.data.totalAgents).toBe(1); expect(doneEvent?.data.directorState.agentResponses).toHaveLength(2); expect(doneEvent?.data.directorState.agentResponses.at(-1)).toEqual( expect.objectContaining({ agentId: 'default-1', contentPreview: 'Fresh answer.', }), ); expect(doneEvent?.data.directorState.turnCount).toBe(1); }); it('returns only this turn whiteboard ledger, not the carried-forward history', async () => { // This turn's teacher child streams a real wb_draw_text action, so the turn // ledger is non-empty. A DIFFERENT historical action arrives in directorState. // Cross-turn board state is carried by storeState's snapshot, and Pi child // prompts replay only the current-turn ledger, so the returned ledger must // contain this turn's action and drop the history — not grow unboundedly // across requests (and not collapse to a constant []). mockDirectorWithWhiteboardTeacherTurn( '[{"type":"action","name":"wb_draw_text","params":{"content":"from this turn","x":10,"y":20}},{"type":"text","content":"Here is the key point."}]', ); const body = makeBody(); const { POST } = await import('@/app/api/chat/pi/route'); const response = await POST( makeRequest({ ...body, config: { ...body.config, piEnableWhiteboardTools: true, agentConfigs: [{ ...body.config.agentConfigs[0], allowedActions: ['wb_draw_text'] }], }, directorState: { turnCount: 3, agentResponses: [], whiteboardLedger: [ { actionName: 'wb_draw_text', agentId: 'default-1', agentName: 'Teacher', params: { content: 'from a previous turn', x: 0, y: 0 }, }, ], }, }), ); const events = await readSseEvents(response); const doneEvent = events.find((event) => event.type === 'done'); expect(response.status).toBe(200); const returnedLedger = doneEvent?.data.directorState.whiteboardLedger; // Exactly this turn's action is kept... expect(returnedLedger).toEqual([ expect.objectContaining({ actionName: 'wb_draw_text', params: expect.objectContaining({ content: 'from this turn' }), }), ]); // ...and the carried-forward history is dropped. expect( returnedLedger.some( (record: { params?: { content?: string } }) => record.params?.content === 'from a previous turn', ), ).toBe(false); }); });