godcrm/backend/services/__tests__/AgentLoopService.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

1372 lines
51 KiB
JavaScript

/**
* AgentLoopService Tests — ADR-095: Agent Tool Loop & Backward Compatibility
*
* Ticket #41425: Agent tool loop saves step messages
* - saveStepMessage() persists messages with correct content_type, role, agent_id
* - toAnthropicTools() converts OpenAI→Anthropic tool format
* - sanitizeToolResult() handles circular JSON, large strings
* - resolveAllowedTools() filters tools per agent config
* - getMaxOutputTokens() returns model-aware token limits
* - injectToolContext() injects space_id into tool arguments
*
* Ticket #41426: Backward compatibility Q&A path
* - When agent has no tools → should NOT enter tool loop
* - When agent mode is "ask" or "read" → uses callAgentAI path
* - Response saved via saveStepMessage with contentType 'text'
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ─── Mocks ──────────────────────────────────────────────────────────────────
// vi.hoisted lets us define variables that are accessible inside vi.mock factories
const { mockDbRun, mockDbGet, mockIsPostgres, mockExecuteTool, MOCK_AGENT_TOOLS } = vi.hoisted(() => {
const mockDbRun = vi.fn();
const mockDbGet = vi.fn().mockResolvedValue({ is_processing: true });
const mockIsPostgres = vi.fn(() => false);
const mockExecuteTool = vi.fn();
const MOCK_AGENT_TOOLS = [
{
type: 'function',
function: {
name: 'get_workspace_info',
description: 'Get workspace info',
parameters: { type: 'object', properties: { space_id: { type: 'number' } }, required: ['space_id'] },
},
},
{
type: 'function',
function: {
name: 'query_table_data',
description: 'Query table data',
parameters: { type: 'object', properties: { table_id: { type: 'number' } }, required: ['table_id'] },
},
},
{
type: 'function',
function: {
name: 'get_table_schema',
description: 'Get table schema',
parameters: { type: 'object', properties: { table_id: { type: 'number' } }, required: ['table_id'] },
},
},
{
type: 'function',
function: {
name: 'list_tables',
description: 'List all tables',
parameters: { type: 'object', properties: { space_id: { type: 'number' } } },
},
},
{
type: 'function',
function: {
name: 'analyze_table_data',
description: 'Analyze table data',
parameters: { type: 'object', properties: { table_id: { type: 'number' } }, required: ['table_id'] },
},
},
{
type: 'function',
function: {
name: 'create_dashboard',
description: 'Create a dashboard',
parameters: { type: 'object', properties: { space_id: { type: 'number' }, title: { type: 'string' } } },
},
},
{
type: 'function',
function: {
name: 'update_row',
description: 'Update a row in a table',
parameters: { type: 'object', properties: { table_id: { type: 'number' }, row_id: { type: 'number' }, data: { type: 'object' } } },
},
},
];
return { mockDbRun, mockDbGet, mockIsPostgres, mockExecuteTool, MOCK_AGENT_TOOLS };
});
vi.mock('../../database/connection.js', () => ({
dbRun: (...args) => mockDbRun(...args),
dbGet: (...args) => mockDbGet(...args),
dbAll: vi.fn().mockResolvedValue([]),
isPostgres: () => mockIsPostgres(),
}));
vi.mock('../../utils/logger.js', () => ({
apiLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../chat/agent-execution-shared.js', () => ({
detectProvider: vi.fn((provider, model) => ({
isClaudeCode: provider === 'claude-code',
isCopilot: provider === 'copilot',
isAnthropic: provider === 'anthropic' || (model && model.includes('claude')),
})),
// T-148527 (WP-A): default to "no fresh messages" so existing fixtures
// that didn't anticipate mid-run injection keep producing identical loop
// shapes. Individual tests can re-mock if they want to verify injection.
loadNewMessagesSince: vi.fn(async () => []),
}));
vi.mock('../labs/ai-execution-service.js', () => ({
default: {
executeCopilotCli: vi.fn(),
executeClaudeCode: vi.fn(),
},
}));
vi.mock('../AgentToolsService.js', () => ({
AGENT_TOOLS: MOCK_AGENT_TOOLS,
executeTool: (...args) => mockExecuteTool(...args),
}));
vi.mock('../AgentActivityLogger.js', () => ({
logToolUsed: vi.fn(),
}));
// ─── Imports (after mocks) ──────────────────────────────────────────────────
import {
saveStepMessage,
toAnthropicTools,
getAnthropicText,
getMaxOutputTokens,
sanitizeToolResult,
injectToolContext,
resolveAllowedTools,
agentLoop,
executeAgentToolLoop,
} from '../AgentLoopService.js';
// ═══════════════════════════════════════════════════════════════════════════
// Task 1: Ticket #41425 — Agent tool loop saves step messages
// ═══════════════════════════════════════════════════════════════════════════
describe('ADR-095: AgentLoopService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDbRun.mockResolvedValue({ lastInsertRowid: 1 });
mockIsPostgres.mockReturnValue(false);
});
// ─── saveStepMessage() ──────────────────────────────────────────────────
describe('saveStepMessage()', () => {
it('should INSERT into messages table with correct fields', async () => {
await saveStepMessage(42, {
content: 'Hello from agent',
contentType: 'text',
role: 'assistant',
senderType: 'agent',
agentId: 10,
senderId: 99,
modelUsed: 'gpt-4',
});
expect(mockDbRun).toHaveBeenCalledTimes(2); // INSERT + UPDATE conversations
const [insertSql, insertParams] = mockDbRun.mock.calls[0];
expect(insertSql).toContain('INSERT INTO messages');
expect(insertParams[0]).toBe(42); // conversation_id
expect(insertParams[1]).toBe(99); // sender_id
expect(insertParams[2]).toBe('agent'); // sender_type
expect(insertParams[3]).toBe('assistant'); // role
expect(insertParams[4]).toBe('Hello from agent'); // content
expect(insertParams[5]).toBe('text'); // content_type
expect(insertParams[6]).toBe(10); // agent_id
expect(insertParams[7]).toBe('gpt-4'); // model_used
});
it('should save tool_call content_type with tool_results JSON', async () => {
await saveStepMessage(42, {
content: 'get_workspace_info',
contentType: 'tool_call',
role: 'assistant',
senderType: 'agent',
agentId: 10,
toolResults: { tool: 'get_workspace_info', args: { space_id: 5 } },
});
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[5]).toBe('tool_call'); // content_type
expect(insertParams[11]).toBe(JSON.stringify({ tool: 'get_workspace_info', args: { space_id: 5 } })); // tool_results
});
it('should save tool_result content_type with role "tool"', async () => {
await saveStepMessage(42, {
content: '{"success": true}',
contentType: 'tool_result',
role: 'tool',
senderType: 'agent',
agentId: 10,
});
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[3]).toBe('tool'); // role
expect(insertParams[5]).toBe('tool_result'); // content_type
});
it('should save thinking content_type', async () => {
await saveStepMessage(42, {
content: 'Let me analyze this data...',
contentType: 'thinking',
role: 'assistant',
senderType: 'agent',
agentId: 10,
});
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[5]).toBe('thinking');
});
it('should update conversation updated_at after inserting message', async () => {
await saveStepMessage(42, { content: 'test' });
expect(mockDbRun).toHaveBeenCalledTimes(2);
const [updateSql, updateParams] = mockDbRun.mock.calls[1];
expect(updateSql).toContain('UPDATE conversations');
expect(updateParams[0]).toBe(42);
});
it('should return lastInsertRowid', async () => {
mockDbRun.mockResolvedValueOnce({ lastInsertRowid: 123 });
mockDbRun.mockResolvedValueOnce({}); // UPDATE conversations
const result = await saveStepMessage(42, { content: 'test' });
expect(result).toBe(123);
});
it('should use default values when opts are omitted', async () => {
await saveStepMessage(42, {});
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[1]).toBeNull(); // senderId default null
expect(insertParams[2]).toBe('agent'); // senderType default
expect(insertParams[3]).toBe('assistant'); // role default
expect(insertParams[4]).toBe(''); // content default
expect(insertParams[5]).toBe('text'); // contentType default
expect(insertParams[6]).toBeNull(); // agentId default null
expect(insertParams[7]).toBeNull(); // modelUsed default null
expect(insertParams[8]).toBeNull(); // tokensIn default null
expect(insertParams[9]).toBeNull(); // tokensOut default null
expect(insertParams[10]).toBeNull(); // latencyMs default null
expect(insertParams[11]).toBeNull(); // toolResults default null (no JSON.stringify of null)
expect(insertParams[12]).toBeNull(); // metadata default null
});
it('should use Postgres syntax when isPostgres() returns true', async () => {
mockIsPostgres.mockReturnValue(true);
await saveStepMessage(42, { content: 'test' });
const [insertSql] = mockDbRun.mock.calls[0];
expect(insertSql).toContain('$1');
expect(insertSql).toContain('NOW()');
expect(insertSql).not.toContain("datetime('now')");
});
it('should use SQLite syntax when isPostgres() returns false', async () => {
mockIsPostgres.mockReturnValue(false);
await saveStepMessage(42, { content: 'test' });
const [insertSql] = mockDbRun.mock.calls[0];
expect(insertSql).toContain('?');
expect(insertSql).toContain("datetime('now')");
});
it('should persist token usage and latency metrics', async () => {
await saveStepMessage(42, {
content: 'response',
tokensIn: 500,
tokensOut: 200,
latencyMs: 1234,
modelUsed: 'claude-sonnet-4',
});
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[7]).toBe('claude-sonnet-4'); // model_used
expect(insertParams[8]).toBe(500); // tokens_in
expect(insertParams[9]).toBe(200); // tokens_out
expect(insertParams[10]).toBe(1234); // latency_ms
});
});
// ─── toAnthropicTools() ─────────────────────────────────────────────────
describe('toAnthropicTools()', () => {
it('should convert OpenAI tool format to Anthropic format', () => {
const openaiTools = [
{
type: 'function',
function: {
name: 'get_workspace_info',
description: 'Get workspace info',
parameters: { type: 'object', properties: { space_id: { type: 'number' } }, required: ['space_id'] },
},
},
];
const result = toAnthropicTools(openaiTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
name: 'get_workspace_info',
description: 'Get workspace info',
input_schema: { type: 'object', properties: { space_id: { type: 'number' } }, required: ['space_id'] },
});
});
it('should convert multiple tools', () => {
const tools = [
{ type: 'function', function: { name: 'tool_a', description: 'A', parameters: { type: 'object', properties: {} } } },
{ type: 'function', function: { name: 'tool_b', description: 'B', parameters: { type: 'object', properties: {} } } },
];
const result = toAnthropicTools(tools);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('tool_a');
expect(result[1].name).toBe('tool_b');
});
it('should filter out tools without function.name', () => {
const tools = [
{ type: 'function', function: { name: 'valid_tool', description: 'Valid' } },
{ type: 'function', function: { description: 'Missing name' } },
{ type: 'function' },
null,
];
const result = toAnthropicTools(tools);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('valid_tool');
});
it('should provide defaults for missing description and parameters', () => {
const tools = [
{ type: 'function', function: { name: 'bare_tool' } },
];
const result = toAnthropicTools(tools);
expect(result[0].description).toBe('');
expect(result[0].input_schema).toEqual({ type: 'object', properties: {} });
});
it('should return empty array for null/undefined input', () => {
expect(toAnthropicTools(null)).toEqual([]);
expect(toAnthropicTools(undefined)).toEqual([]);
});
it('should return empty array for empty input', () => {
expect(toAnthropicTools([])).toEqual([]);
});
});
// ─── sanitizeToolResult() ───────────────────────────────────────────────
describe('sanitizeToolResult()', () => {
it('should return the result unchanged for normal objects', () => {
const input = { success: true, data: [1, 2, 3] };
const result = sanitizeToolResult(input);
expect(result).toEqual(input);
});
it('should truncate results larger than 50000 characters', () => {
const largeData = 'x'.repeat(60000);
const input = { data: largeData };
const result = sanitizeToolResult(input);
expect(result._truncated).toBe(true);
expect(result.data.length).toBeLessThanOrEqual(50003); // 50000 + '...'
expect(result.data).toContain('...');
});
it('should handle circular JSON references gracefully', () => {
const obj = { a: 1 };
obj.self = obj; // circular reference
const result = sanitizeToolResult(obj);
expect(result).toEqual({ success: false, error: 'Result not serializable' });
});
it('should return error object for null/undefined input', () => {
expect(sanitizeToolResult(null)).toEqual({ success: false, error: 'No result' });
expect(sanitizeToolResult(undefined)).toEqual({ success: false, error: 'No result' });
});
it('should handle empty string as falsy', () => {
// Empty string is falsy in JS
const result = sanitizeToolResult('');
expect(result).toEqual({ success: false, error: 'No result' });
});
it('should pass through normal-sized results without modification', () => {
const input = { success: true, rows: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Row ${i}` })) };
const result = sanitizeToolResult(input);
expect(result).toEqual(input);
expect(result._truncated).toBeUndefined();
});
it('should handle string results', () => {
const result = sanitizeToolResult('simple string');
expect(result).toBe('simple string');
});
it('should handle number results', () => {
const result = sanitizeToolResult(42);
expect(result).toBe(42);
});
});
// ─── resolveAllowedTools() ──────────────────────────────────────────────
describe('resolveAllowedTools()', () => {
it('should return ALL AGENT_TOOLS when no tools configured', async () => {
const result = await resolveAllowedTools({}, null);
expect(result).toEqual(MOCK_AGENT_TOOLS);
});
it('should filter tools by array of tool names', async () => {
const result = await resolveAllowedTools({
tools: ['update_row'],
}, null);
const names = result.map(t => t.function.name);
// Should include the specified tool plus base consulting tools
expect(names).toContain('update_row');
expect(names).toContain('get_workspace_info');
expect(names).toContain('query_table_data');
expect(names).toContain('get_table_schema');
expect(names).toContain('list_tables');
expect(names).toContain('analyze_table_data');
});
it('should parse JSON string tool list', async () => {
const result = await resolveAllowedTools({
tools: JSON.stringify(['update_row', 'create_dashboard']),
}, null);
const names = result.map(t => t.function.name);
expect(names).toContain('update_row');
expect(names).toContain('create_dashboard');
// Base consulting tools always included
expect(names).toContain('get_workspace_info');
});
it('should parse comma-separated tool list', async () => {
const result = await resolveAllowedTools({
tools: 'update_row, create_dashboard',
}, null);
const names = result.map(t => t.function.name);
expect(names).toContain('update_row');
expect(names).toContain('create_dashboard');
});
it('should always include base consulting tools', async () => {
const result = await resolveAllowedTools({
tools: ['create_dashboard'],
}, null);
const names = result.map(t => t.function.name);
const baseTools = ['get_workspace_info', 'query_table_data', 'get_table_schema', 'list_tables', 'analyze_table_data'];
for (const baseTool of baseTools) {
expect(names).toContain(baseTool);
}
});
it('should use allowed_tools as fallback config key', async () => {
const result = await resolveAllowedTools({
allowed_tools: ['update_row'],
}, null);
const names = result.map(t => t.function.name);
expect(names).toContain('update_row');
});
it('should fall back to full AGENT_TOOLS if filtered list is empty', async () => {
const result = await resolveAllowedTools({
tools: ['nonexistent_tool_xyz'],
}, null);
// The specified tool does not exist in AGENT_TOOLS, but base consulting tools do.
// Since filtered includes base tools, it should NOT be empty.
// However, if the agent specifies ONLY nonexistent tools AND base tools also don't match,
// it would fall back to full list. With our mock, base tools DO exist.
expect(result.length).toBeGreaterThan(0);
});
});
// ─── getMaxOutputTokens() ──────────────────────────────────────────────
describe('getMaxOutputTokens()', () => {
it('should return 32000 for claude-opus-4', () => {
expect(getMaxOutputTokens('claude-opus-4')).toBe(32000);
});
it('should return 16000 for claude-sonnet-4', () => {
expect(getMaxOutputTokens('claude-sonnet-4')).toBe(16000);
});
it('should return 8192 for claude-3-5-sonnet', () => {
expect(getMaxOutputTokens('claude-3-5-sonnet-20241022')).toBe(8192);
});
it('should return 8192 for claude-3.5-sonnet variant', () => {
expect(getMaxOutputTokens('claude-3.5-sonnet')).toBe(8192);
});
it('should return 16384 for gpt-4o', () => {
expect(getMaxOutputTokens('gpt-4o')).toBe(16384);
});
it('should return 8192 for gpt-4', () => {
expect(getMaxOutputTokens('gpt-4-turbo')).toBe(8192);
});
it('should return 100000 for o1/o3/o4 reasoning models', () => {
expect(getMaxOutputTokens('o1-preview')).toBe(100000);
expect(getMaxOutputTokens('o3-mini')).toBe(100000);
expect(getMaxOutputTokens('o4-mini')).toBe(100000);
});
it('should return 8192 as default for unknown models', () => {
expect(getMaxOutputTokens('some-unknown-model')).toBe(8192);
});
it('should return 8192 as default when modelId is null/undefined', () => {
expect(getMaxOutputTokens(null)).toBe(8192);
expect(getMaxOutputTokens(undefined)).toBe(8192);
});
it('should respect agentConfig.max_tokens override', () => {
expect(getMaxOutputTokens('gpt-4', { max_tokens: 2048 })).toBe(2048);
});
it('should ignore agentConfig.max_tokens if not positive', () => {
expect(getMaxOutputTokens('gpt-4', { max_tokens: 0 })).toBe(8192);
expect(getMaxOutputTokens('gpt-4', { max_tokens: -1 })).toBe(8192);
});
it('should handle case insensitivity for model names', () => {
expect(getMaxOutputTokens('Claude-Opus-4')).toBe(32000);
expect(getMaxOutputTokens('CLAUDE-SONNET-4')).toBe(16000);
expect(getMaxOutputTokens('GPT-4O')).toBe(16384);
});
});
// ─── injectToolContext() ────────────────────────────────────────────────
describe('injectToolContext()', () => {
it('should inject space_id for get_workspace_info when not provided', () => {
const result = injectToolContext('get_workspace_info', {}, { spaceId: 11, userId: 1 });
expect(result.space_id).toBe(11);
});
it('should inject space_id for create_dashboard when not provided', () => {
const result = injectToolContext('create_dashboard', { title: 'My Dashboard' }, { spaceId: 11, userId: 1 });
expect(result.space_id).toBe(11);
expect(result.title).toBe('My Dashboard');
});
it('should NOT overwrite existing space_id', () => {
const result = injectToolContext('get_workspace_info', { space_id: 99 }, { spaceId: 11, userId: 1 });
expect(result.space_id).toBe(99); // Keep original
});
it('should inject space_id for list_tables when no project_id or space_id', () => {
const result = injectToolContext('list_tables', {}, { spaceId: 11, userId: 1 });
expect(result.space_id).toBe(11);
});
it('should NOT inject space_id for list_tables when project_id is present', () => {
const result = injectToolContext('list_tables', { project_id: 5 }, { spaceId: 11, userId: 1 });
expect(result.space_id).toBeUndefined();
expect(result.project_id).toBe(5);
});
it('should NOT inject space_id for tools not in the injection lists', () => {
const result = injectToolContext('query_table_data', { table_id: 100 }, { spaceId: 11, userId: 1 });
expect(result.space_id).toBeUndefined();
expect(result.table_id).toBe(100);
});
it('should not inject space_id when context has no spaceId', () => {
const result = injectToolContext('get_workspace_info', {}, { userId: 1 });
expect(result.space_id).toBeUndefined();
});
it('should not mutate the original args object', () => {
const original = { table_id: 100 };
const result = injectToolContext('get_workspace_info', original, { spaceId: 11, userId: 1 });
expect(original.space_id).toBeUndefined(); // Original unchanged
expect(result.space_id).toBe(11);
});
});
// ─── getAnthropicText() ─────────────────────────────────────────────────
describe('getAnthropicText()', () => {
it('should return text from content blocks array', () => {
const blocks = [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
];
expect(getAnthropicText(blocks)).toBe('Hello \nWorld');
});
it('should filter out non-text blocks', () => {
const blocks = [
{ type: 'text', text: 'Analysis:' },
{ type: 'tool_use', id: 'tu_1', name: 'query_table_data', input: {} },
{ type: 'text', text: 'Here are the results.' },
];
expect(getAnthropicText(blocks)).toBe('Analysis:\nHere are the results.');
});
it('should return string content directly', () => {
expect(getAnthropicText('Hello World')).toBe('Hello World');
});
it('should return empty string for null/undefined', () => {
expect(getAnthropicText(null)).toBe('');
expect(getAnthropicText(undefined)).toBe('');
});
it('should return empty string for empty array', () => {
expect(getAnthropicText([])).toBe('');
});
it('should skip text blocks with falsy text', () => {
const blocks = [
{ type: 'text', text: '' },
{ type: 'text', text: null },
{ type: 'text', text: 'Actual text' },
];
expect(getAnthropicText(blocks)).toBe('Actual text');
});
});
// ─── executeAgentToolLoop alias ─────────────────────────────────────────
describe('executeAgentToolLoop alias', () => {
it('should be the same function as agentLoop', () => {
expect(executeAgentToolLoop).toBe(agentLoop);
});
});
// ═══════════════════════════════════════════════════════════════════════
// Task 2: Ticket #41426 — Backward compatibility Q&A path
// ═══════════════════════════════════════════════════════════════════════
describe('ADR-095: Backward compatibility — Q&A path routing', () => {
/**
* These tests verify the routing logic from chat.js (ADR-095 Task 1+4):
*
* if (agent_mode === 'agent' && hasTools) → executeAgentToolLoop()
* else → callAgentAI() (simple Q&A)
*
* We test the building blocks that enable this routing:
* - resolveAllowedTools returns tools/empty based on config
* - saveStepMessage works for simple text responses (Q&A path)
* - agentLoop safety net ensures a final text response
*/
describe('Q&A routing conditions', () => {
it('agent with no tools config returns ALL tools (defaults to tool loop eligible)', async () => {
// When no tools are configured, resolveAllowedTools returns all AGENT_TOOLS
const tools = await resolveAllowedTools({}, null);
expect(tools.length).toBe(MOCK_AGENT_TOOLS.length);
expect(tools.length).toBeGreaterThan(0);
});
it('agent with explicit empty tools array returns ALL tools (fallback)', async () => {
// Empty array means "no specific filter" → returns all tools
const tools = await resolveAllowedTools({ tools: [] }, null);
expect(tools.length).toBe(MOCK_AGENT_TOOLS.length);
});
it('Q&A response should be saved via saveStepMessage with contentType "text"', async () => {
// Simulates the Q&A path in chat.js where callAgentAI response is saved
await saveStepMessage(42, {
content: 'Here is the answer to your question.',
contentType: 'text',
role: 'assistant',
senderType: 'agent',
agentId: 10,
senderId: 99,
modelUsed: 'gpt-4',
metadata: JSON.stringify({ agent_name: 'Helper Bot', agent_icon: 'bot', agent_row_id: 10 }),
});
expect(mockDbRun).toHaveBeenCalledTimes(2);
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[5]).toBe('text'); // content_type is 'text' for Q&A
expect(insertParams[3]).toBe('assistant'); // role
expect(insertParams[6]).toBe(10); // agent_id
expect(insertParams[12]).toContain('Helper Bot'); // metadata includes agent name
});
it('Q&A path should NOT produce tool_call or tool_result messages', async () => {
// In Q&A mode, only a single text message is saved
await saveStepMessage(42, {
content: 'Simple response without tools',
contentType: 'text',
role: 'assistant',
agentId: 10,
});
// Only 2 DB calls: INSERT message + UPDATE conversations
expect(mockDbRun).toHaveBeenCalledTimes(2);
const [, insertParams] = mockDbRun.mock.calls[0];
expect(insertParams[5]).toBe('text');
expect(insertParams[11]).toBeNull(); // no tool_results
});
});
describe('agentLoop safety net (ADR-095 Task 2)', () => {
it('should produce a safety net message when tool loop returns no text', async () => {
// Mock global fetch for Anthropic API that returns an empty response
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
content: [],
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 0 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'You are a helpful assistant.',
history: [],
userMessage: 'Hello',
agentConfig: { max_iterations: 1 },
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Safety net should produce a fallback message with iteration limit info
const expectedFallback = '\u26a0\ufe0f Agent reached iteration limit (1). The task may be incomplete. Please retry or increase max_iterations in agent settings.';
expect(result).toBe(expectedFallback);
// saveStepMessage should have been called with the safety text
const saveCall = mockDbRun.mock.calls.find(([sql, params]) =>
sql.includes('INSERT INTO messages') && params[4] === expectedFallback
);
expect(saveCall).toBeTruthy();
expect(saveCall[1][5]).toBe('text'); // content_type
} finally {
globalThis.fetch = originalFetch;
}
});
it('should return the AI text response when tool loop completes normally', async () => {
// Mock Anthropic API returning a text-only response (no tool use)
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
content: [{ type: 'text', text: 'Here is my analysis of the data.' }],
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 50 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'You are a helpful assistant.',
history: [],
userMessage: 'Analyze this',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
expect(result).toBe('Here is my analysis of the data.');
// Verify the text was saved as a step message
const saveCall = mockDbRun.mock.calls.find(([sql, params]) =>
sql.includes('INSERT INTO messages') && params[4] === 'Here is my analysis of the data.'
);
expect(saveCall).toBeTruthy();
expect(saveCall[1][5]).toBe('text'); // content_type
expect(saveCall[1][3]).toBe('assistant'); // role
expect(saveCall[1][6]).toBe(10); // agent_id
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('agentLoop with Anthropic tool execution', () => {
it('should save tool_call and tool_result step messages during tool loop', async () => {
// Iteration 1: AI wants to use a tool
// Iteration 2: AI responds with final text
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
content: [
{ type: 'text', text: 'Let me check the workspace.' },
{ type: 'tool_use', id: 'tu_001', name: 'get_workspace_info', input: { space_id: 11 } },
],
stop_reason: 'tool_use',
usage: { input_tokens: 100, output_tokens: 50 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
content: [{ type: 'text', text: 'Your workspace has 5 tables.' }],
stop_reason: 'end_turn',
usage: { input_tokens: 200, output_tokens: 60 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true, tables: ['Users', 'Tasks', 'Projects', 'Notes', 'Tags'] });
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'You are a helpful assistant.',
history: [],
userMessage: 'What tables are in my workspace?',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
expect(result).toBe('Your workspace has 5 tables.');
// Verify step messages were saved:
// 1. thinking message ("Let me check the workspace.")
// 2. tool_call message (get_workspace_info)
// 3. tool_result message
// 4. final text response
const insertCalls = mockDbRun.mock.calls.filter(([sql]) => sql.includes('INSERT INTO messages'));
// Find the thinking message
const thinkingCall = insertCalls.find(([, params]) => params[5] === 'thinking');
expect(thinkingCall).toBeTruthy();
expect(thinkingCall[1][4]).toBe('Let me check the workspace.');
// Find the tool_call message
const toolCallSave = insertCalls.find(([, params]) => params[5] === 'tool_call');
expect(toolCallSave).toBeTruthy();
expect(toolCallSave[1][4]).toBe('get_workspace_info'); // content = tool name
expect(toolCallSave[1][6]).toBe(10); // agent_id
// Find the tool_result message
const toolResultSave = insertCalls.find(([, params]) => params[5] === 'tool_result');
expect(toolResultSave).toBeTruthy();
expect(toolResultSave[1][3]).toBe('tool'); // role = 'tool'
// Find the final text message
const finalTextSave = insertCalls.find(([, params]) => params[5] === 'text' && params[4] === 'Your workspace has 5 tables.');
expect(finalTextSave).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
}
});
it('should handle API errors gracefully and trigger safety net', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'You are a helpful assistant.',
history: [],
userMessage: 'Hello',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Safety net kicks in since no text was produced (default max_iterations=25)
expect(result).toBe('\u26a0\ufe0f Agent reached iteration limit (25). The task may be incomplete. Please retry or increase max_iterations in agent settings.');
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('agentLoop with OpenAI provider', () => {
it('should save step messages for OpenAI tool calls', async () => {
// Iteration 1: OpenAI wants to call a function
// Iteration 2: OpenAI returns final text
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: 'Let me look that up.',
tool_calls: [{
id: 'call_001',
type: 'function',
function: { name: 'query_table_data', arguments: '{"table_id": 5}' },
}],
},
finish_reason: 'tool_calls',
}],
usage: { prompt_tokens: 100, completion_tokens: 50 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: { content: 'Found 10 rows in the table.' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 200, completion_tokens: 60 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true, rows: Array(10).fill({ id: 1 }) });
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'You are a helpful assistant.',
history: [],
userMessage: 'Show me table 5 data',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'gpt-4-turbo', provider: 'openai', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
expect(result).toBe('Found 10 rows in the table.');
// Verify step messages
const insertCalls = mockDbRun.mock.calls.filter(([sql]) => sql.includes('INSERT INTO messages'));
// thinking step (before tool calls)
const thinkingCall = insertCalls.find(([, params]) => params[5] === 'thinking');
expect(thinkingCall).toBeTruthy();
expect(thinkingCall[1][4]).toBe('Let me look that up.');
// tool_call step
const toolCallSave = insertCalls.find(([, params]) => params[5] === 'tool_call');
expect(toolCallSave).toBeTruthy();
expect(toolCallSave[1][4]).toBe('query_table_data');
// tool_result step
const toolResultSave = insertCalls.find(([, params]) => params[5] === 'tool_result');
expect(toolResultSave).toBeTruthy();
expect(toolResultSave[1][3]).toBe('tool');
// Final text
const finalSave = insertCalls.find(([, params]) => params[5] === 'text');
expect(finalSave).toBeTruthy();
expect(finalSave[1][4]).toBe('Found 10 rows in the table.');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should use correct API URL for OpenAI provider', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: 'Response' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Hello',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'gpt-4', provider: 'openai', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: null,
userId: 1,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.openai.com/v1/chat/completions',
expect.any(Object)
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should use correct API URL for OpenRouter provider', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: 'Response' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Hello',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'gpt-4', provider: 'openrouter', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: null,
userId: 1,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.any(Object)
);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('agentLoop max_iterations enforcement', () => {
it('should respect agentConfig.max_iterations', async () => {
// Return tool_use every time to force iteration
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
content: [
{ type: 'tool_use', id: 'tu_loop', name: 'get_workspace_info', input: { space_id: 1 } },
],
stop_reason: 'tool_use',
usage: { input_tokens: 100, output_tokens: 50 },
}),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true });
try {
await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Hello',
agentConfig: { max_iterations: 3 },
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Should have made 3 tool-loop calls + 1 summary call (no text response after 3 iterations)
expect(mockFetch).toHaveBeenCalledTimes(4);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should default to 10 max_iterations when not configured', async () => {
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount >= 10) {
// On the 10th call, return end_turn to prevent any surprises
return {
ok: true,
json: async () => ({
content: [{ type: 'text', text: 'Done' }],
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 50 },
}),
};
}
return {
ok: true,
json: async () => ({
content: [
{ type: 'tool_use', id: `tu_${callCount}`, name: 'get_workspace_info', input: { space_id: 1 } },
],
stop_reason: 'tool_use',
usage: { input_tokens: 100, output_tokens: 50 },
}),
};
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true });
try {
await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Hello',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Should stop at or before 10 iterations
expect(mockFetch.mock.calls.length).toBeLessThanOrEqual(10);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('Summary call instead of generic safety net', () => {
it('should make a summary call when Anthropic tool loop ends without text response', async () => {
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) {
// First call: tool_use
return {
ok: true,
json: async () => ({
content: [
{ type: 'tool_use', id: 'tu_1', name: 'get_workspace_info', input: { space_id: 1 } },
],
stop_reason: 'tool_use',
usage: { input_tokens: 100, output_tokens: 50 },
}),
};
}
if (callCount === 2) {
// Second call: another tool_use, no text — forces tool_result but no more iterations
return {
ok: true,
json: async () => ({
content: [],
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 10 },
}),
};
}
// Third call: summary call — return text summary
return {
ok: true,
json: async () => ({
content: [{ type: 'text', text: 'Here is what I accomplished: created workspace.' }],
stop_reason: 'end_turn',
usage: { input_tokens: 200, output_tokens: 80 },
}),
};
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true });
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Create workspace',
agentConfig: { max_iterations: 5 },
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Should get summary text instead of "Task completed"
expect(result).toBe('Here is what I accomplished: created workspace.');
// 2 loop calls + 1 summary call = 3
expect(mockFetch).toHaveBeenCalledTimes(3);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should make a summary call for OpenAI when tool loop ends without text', async () => {
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) {
return {
ok: true,
json: async () => ({
choices: [{
message: {
content: null,
tool_calls: [{
id: 'call_1', type: 'function',
function: { name: 'get_workspace_info', arguments: '{"space_id":1}' }
}]
},
finish_reason: 'tool_calls'
}],
usage: { prompt_tokens: 100, completion_tokens: 50 },
}),
};
}
if (callCount === 2) {
// No more tool calls, but empty content
return {
ok: true,
json: async () => ({
choices: [{
message: { content: '', tool_calls: undefined },
finish_reason: 'stop'
}],
usage: { prompt_tokens: 100, completion_tokens: 10 },
}),
};
}
// Summary call
return {
ok: true,
json: async () => ({
choices: [{
message: { content: 'Summary: workspace info retrieved successfully.' },
finish_reason: 'stop'
}],
usage: { prompt_tokens: 200, completion_tokens: 60 },
}),
};
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true });
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Get info',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'gpt-4o', provider: 'openai', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
expect(result).toBe('Summary: workspace info retrieved successfully.');
expect(mockFetch).toHaveBeenCalledTimes(3);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should fall back to generic message when summary call fails', async () => {
const mockFetch = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
content: [
{ type: 'tool_use', id: 'tu_1', name: 'get_workspace_info', input: { space_id: 1 } },
],
stop_reason: 'tool_use',
usage: { input_tokens: 100, output_tokens: 50 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
content: [],
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 10 },
}),
})
// Summary call fails
.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
mockExecuteTool.mockResolvedValue({ success: true });
try {
const result = await agentLoop({
conversationId: 42,
systemPrompt: 'Test',
history: [],
userMessage: 'Do something',
agentConfig: {},
resolved: { apiKey: 'sk-test', model: 'claude-sonnet-4', provider: 'anthropic', isLocal: false },
agentRowId: 10,
senderId: 99,
spaceId: 11,
userId: 1,
});
// Falls back to generic message with iteration limit (default max_iterations=25)
expect(result).toBe('\u26a0\ufe0f Agent reached iteration limit (25). The task may be incomplete. Please retry or increase max_iterations in agent settings.');
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
});