import { describe, expect, it } from 'vitest'; import { buildCallAgentTool } from '@/lib/chat/pi/tools/call-agent'; import { buildCueUserTool } from '@/lib/chat/pi/tools/cue-user'; describe('Pi chat cue_user tool', () => { it('emits the existing cue_user payload once', async () => { const events: unknown[] = []; let userCued = false; const tool = buildCueUserTool({ getLastAgentId: () => 'default-1', cueUser: async (data) => { if (userCued) return false; userCued = true; events.push({ type: 'cue_user', data }); return true; }, }); const first = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); const second = await tool.execute('cue-2', { prompt: 'Again?' }); expect(events).toEqual([ { type: 'cue_user', data: { fromAgentId: 'default-1', prompt: 'Any follow-up?' }, }, ]); expect(first.details).toEqual({ emitted: true }); expect(second.details).toEqual({ emitted: false }); }); it('does not emit cue_user before any classroom agent turn when guarded', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => undefined, canCueUser: () => false, cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([]); expect(result.details).toEqual({ emitted: false, skipped: true, reason: 'no_agent_turns', }); }); it('soft-skips cue_user with a teacher substantive reason when configured', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => 'student-1', canCueUser: () => false, cueUserSkipReason: 'no_substantive_teacher_turn', cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([]); expect(result.details).toEqual({ emitted: false, skipped: true, reason: 'no_substantive_teacher_turn', }); }); it('soft-skips cue_user with a teacher-or-assistant substantive reason when configured', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => 'student-1', canCueUser: () => false, cueUserSkipReason: 'no_substantive_teaching_turn', cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([]); expect(result.content).toEqual([ { type: 'text', text: 'Call the teacher or teaching assistant for a visible answer before cueing the user.', }, ]); expect(result.details).toEqual({ emitted: false, skipped: true, reason: 'no_substantive_teaching_turn', }); }); it('allows cue_user after a teaching assistant substantive turn when guarded', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => 'assistant-1', canCueUser: () => true, cueUserSkipReason: 'no_substantive_teaching_turn', cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([ { type: 'cue_user', data: { fromAgentId: 'assistant-1', prompt: 'Any follow-up?' }, }, ]); expect(result.details).toEqual({ emitted: true }); }); it('allows cue_user after a teacher substantive turn when guarded', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => 'teacher-1', canCueUser: () => true, cueUserSkipReason: 'no_substantive_teacher_turn', cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([ { type: 'cue_user', data: { fromAgentId: 'teacher-1', prompt: 'Any follow-up?' }, }, ]); expect(result.details).toEqual({ emitted: true }); }); it('refuses cue_user after the session is closed', async () => { const events: unknown[] = []; const tool = buildCueUserTool({ getLastAgentId: () => 'default-1', isSessionClosed: () => true, cueUser: async (data) => { events.push({ type: 'cue_user', data }); return true; }, }); const result = await tool.execute('cue-1', { prompt: 'Any follow-up?' }); expect(events).toEqual([]); expect(result.details).toEqual({ emitted: false, skipped: true, reason: 'session_closed', }); }); it('skips call_agent after the user has already been cued', async () => { const tool = buildCallAgentTool({ body: { messages: [], storeState: { stage: { id: 'stage-1', name: 'Stage' }, scenes: [], currentSceneId: null, whiteboardOpen: false, }, config: { agentIds: ['default-1'] }, apiKey: '', } as never, agentConfigs: [], send: async () => {}, languageModel: {} as never, onAgentDone: () => {}, onActionDone: () => {}, thinkingConfig: { mode: 'disabled', enabled: false }, abortSignal: new AbortController().signal, maxAgentTurns: 6, getAgentTurnCount: () => 0, getAgentResponses: () => [], getWhiteboardLedger: () => [], maxActionsPerAgent: 1, enableWhiteboardTools: false, isUserCued: () => true, }); const result = await tool.execute('call-1', { agentId: 'default-1', instruction: 'Please answer.', }); expect(result.details).toEqual({ skipped: true, reason: 'user_already_cued' }); }); it('skips call_agent after the session is closed', async () => { const tool = buildCallAgentTool({ body: { messages: [], storeState: { stage: { id: 'stage-1', name: 'Stage' }, scenes: [], currentSceneId: null, whiteboardOpen: false, }, config: { agentIds: ['default-1'] }, apiKey: '', } as never, agentConfigs: [], send: async () => {}, languageModel: {} as never, onAgentDone: () => {}, onActionDone: () => {}, thinkingConfig: { mode: 'disabled', enabled: false }, abortSignal: new AbortController().signal, maxAgentTurns: 6, getAgentTurnCount: () => 0, getAgentResponses: () => [], getWhiteboardLedger: () => [], maxActionsPerAgent: 1, enableWhiteboardTools: false, isSessionClosed: () => true, }); const result = await tool.execute('call-1', { agentId: 'default-1', instruction: 'Please answer.', }); expect(result.details).toEqual({ skipped: true, reason: 'session_closed' }); }); it('counts rejected call_agent requests toward the hard attempt cap', async () => { const tool = buildCallAgentTool({ body: { messages: [], storeState: { stage: { id: 'stage-1', name: 'Stage' }, scenes: [], currentSceneId: null, whiteboardOpen: false, }, config: { agentIds: ['default-1'] }, apiKey: '', } as never, agentConfigs: [], send: async () => {}, languageModel: {} as never, onAgentDone: () => {}, onActionDone: () => {}, thinkingConfig: { mode: 'disabled', enabled: false }, abortSignal: new AbortController().signal, maxAgentTurns: 1, getAgentTurnCount: () => 0, getAgentResponses: () => [], getWhiteboardLedger: () => [], maxActionsPerAgent: 1, enableWhiteboardTools: false, }); for (let i = 0; i < 4; i += 1) { await expect( tool.execute(`invalid-${i}`, { agentId: 'missing-agent', instruction: 'Please answer.', }), ).resolves.toMatchObject({ details: { skipped: true, reason: 'invalid_agent_id' }, }); } await expect( tool.execute('over-cap', { agentId: 'missing-agent', instruction: 'Please answer.', }), ).resolves.toMatchObject({ details: { skipped: true, reason: 'agent_attempt_cap', totalAgentAttempts: 4 }, }); }); it('does not allow any call_agent after the agent turn budget is reached', async () => { const tool = buildCallAgentTool({ body: { messages: [], storeState: { stage: { id: 'stage-1', name: 'Stage' }, scenes: [], currentSceneId: null, whiteboardOpen: false, }, config: { agentIds: ['teacher-1'] }, apiKey: '', } as never, agentConfigs: [ { id: 'teacher-1', name: 'Teacher', role: 'teacher', persona: 'Summarize clearly.', avatar: '', color: '#3366ff', allowedActions: [], priority: 10, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), isDefault: true, }, ], send: async () => {}, languageModel: {} as never, onAgentDone: () => {}, onActionDone: () => {}, thinkingConfig: { mode: 'disabled', enabled: false }, abortSignal: new AbortController().signal, maxAgentTurns: 1, getAgentTurnCount: () => 1, getAgentResponses: () => [], getWhiteboardLedger: () => [], maxActionsPerAgent: 1, enableWhiteboardTools: false, }); expect( (tool.parameters as { properties?: Record }).properties, ).not.toHaveProperty('turnKind'); const result = await tool.execute('call-1', { agentId: 'teacher-1', instruction: 'Give a final summary.', }); expect(result.details).toEqual({ skipped: true, reason: 'agent_turn_limit', maxAgentTurns: 1, }); }); });