Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
949 lines
37 KiB
JavaScript
949 lines
37 KiB
JavaScript
/**
|
|
* ADR-095: Chat Agent Loop Integration Tests
|
|
*
|
|
* Ticket #41425: Agent tool loop saves step messages
|
|
* - When agent_mode='agent' AND agent has tools -> executeAgentToolLoop() is called
|
|
* - Step messages (thinking, tool_call, tool_result, text) saved with correct content_type
|
|
* - Each step message has correct role, agent_id, sender_id
|
|
* - Safety net: if tool loop produces no final text -> fallback message is saved
|
|
* - Multiple tool iterations -> all steps persisted in order
|
|
*
|
|
* Ticket #41426: Backward compatibility Q&A path
|
|
* - When agent has NO tools -> callAgentAI() is used (NOT tool loop)
|
|
* - When agent_mode='ask' -> simple Q&A regardless of tools
|
|
* - When agent_mode='read' -> simple Q&A regardless of tools
|
|
* - Q&A response saved via saveStepMessage with contentType='text'
|
|
* - Legacy conversations without agent_mode field -> default to Q&A
|
|
*
|
|
* Strategy:
|
|
* We mock the AgentLoopService imports (executeAgentToolLoop, saveStepMessage,
|
|
* resolveAllowedTools) and the database layer, then import the chat module to
|
|
* test the triggerAgentResponse routing logic via the HTTP endpoint.
|
|
* The endpoint handler runs agent responses asynchronously (fire-and-forget),
|
|
* so we await a brief delay to let the async execution settle.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
// ─── Hoisted mocks (accessible inside vi.mock factories) ────────────────────
|
|
|
|
const {
|
|
mockDbRun, mockDbGet, mockDbAll, mockIsPostgres,
|
|
mockExecuteAgentToolLoop, mockSaveStepMessage, mockResolveAllowedTools,
|
|
mockResolveAgentProvider, mockBuildAgentSystemPrompt,
|
|
mockLoadConversationHistory, mockFetchBoundRowContext,
|
|
mockDetectProvider, mockGetHistoryLimit,
|
|
mockLogAgentActivity, mockLogMessageSent, mockLogAgentMentioned,
|
|
mockLogToolUsed, mockLogAgentError, mockLogTaskCompleted,
|
|
mockIsMessageRelevantToAgent, mockResolveAgentUserService,
|
|
mockPaginateByBubbles,
|
|
MOCK_AGENT_TOOLS,
|
|
} = vi.hoisted(() => {
|
|
const mockDbRun = vi.fn();
|
|
const mockDbGet = vi.fn();
|
|
const mockDbAll = vi.fn();
|
|
const mockIsPostgres = vi.fn(() => false);
|
|
const mockExecuteAgentToolLoop = vi.fn();
|
|
const mockSaveStepMessage = vi.fn();
|
|
const mockResolveAllowedTools = vi.fn();
|
|
const mockResolveAgentProvider = vi.fn();
|
|
const mockBuildAgentSystemPrompt = vi.fn();
|
|
const mockLoadConversationHistory = vi.fn();
|
|
const mockFetchBoundRowContext = vi.fn();
|
|
const mockDetectProvider = vi.fn();
|
|
const mockGetHistoryLimit = vi.fn();
|
|
const mockLogAgentActivity = vi.fn();
|
|
const mockLogMessageSent = vi.fn();
|
|
const mockLogAgentMentioned = vi.fn();
|
|
const mockLogToolUsed = vi.fn();
|
|
const mockLogAgentError = vi.fn();
|
|
const mockLogTaskCompleted = vi.fn();
|
|
const mockIsMessageRelevantToAgent = vi.fn();
|
|
const mockResolveAgentUserService = vi.fn();
|
|
const mockPaginateByBubbles = 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' } } },
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'query_table_data',
|
|
description: 'Query table data',
|
|
parameters: { type: 'object', properties: { table_id: { type: 'number' } } },
|
|
},
|
|
},
|
|
];
|
|
|
|
return {
|
|
mockDbRun, mockDbGet, mockDbAll, mockIsPostgres,
|
|
mockExecuteAgentToolLoop, mockSaveStepMessage, mockResolveAllowedTools,
|
|
mockResolveAgentProvider, mockBuildAgentSystemPrompt,
|
|
mockLoadConversationHistory, mockFetchBoundRowContext,
|
|
mockDetectProvider, mockGetHistoryLimit,
|
|
mockLogAgentActivity, mockLogMessageSent, mockLogAgentMentioned,
|
|
mockLogToolUsed, mockLogAgentError, mockLogTaskCompleted,
|
|
mockIsMessageRelevantToAgent, mockResolveAgentUserService,
|
|
mockPaginateByBubbles,
|
|
MOCK_AGENT_TOOLS,
|
|
};
|
|
});
|
|
|
|
// ─── Module mocks ────────────────────────────────────────────────────────────
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbRun: (...args) => mockDbRun(...args),
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
isPostgres: () => mockIsPostgres(),
|
|
safeJsonParse: (str, fallback = null) => {
|
|
if (!str) return fallback;
|
|
if (typeof str !== 'string') return str;
|
|
try { return JSON.parse(str); } catch { return fallback; }
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => ({
|
|
apiLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
trace: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../utils/response.js', () => ({
|
|
success: (res, data, msg) => res.json({ success: true, data, message: msg }),
|
|
created: (res, data, msg) => res.status(201).json({ success: true, data, message: msg }),
|
|
error: (res, msg, code) => {
|
|
const statusCode = (typeof code === 'number' && code >= 100 && code < 600) ? code : 500;
|
|
return res.status(statusCode).json({ success: false, message: typeof msg === 'string' ? msg : 'Internal error' });
|
|
},
|
|
badRequest: (res, msg) => res.status(400).json({ success: false, message: msg }),
|
|
notFound: (res, msg) => res.status(404).json({ success: false, message: msg }),
|
|
forbidden: (res, msg) => res.status(403).json({ success: false, message: msg }),
|
|
unauthorized: (res, msg) => res.status(401).json({ success: false, message: msg }),
|
|
}));
|
|
|
|
vi.mock('../../../utils/bubblePagination.js', () => ({
|
|
paginateByBubbles: (...args) => mockPaginateByBubbles(...args),
|
|
BUBBLE_PAGE_SIZE: 20,
|
|
}));
|
|
|
|
vi.mock('../../../services/chat/response-mode.js', () => ({
|
|
isMessageRelevantToAgent: (...args) => mockIsMessageRelevantToAgent(...args),
|
|
}));
|
|
|
|
vi.mock('../../../services/agent-users.js', () => ({
|
|
resolveAgentUser: (...args) => mockResolveAgentUserService(...args),
|
|
}));
|
|
|
|
vi.mock('../../../services/chat/agent-execution-shared.js', () => ({
|
|
resolveAgentProvider: (...args) => mockResolveAgentProvider(...args),
|
|
buildAgentSystemPrompt: (...args) => mockBuildAgentSystemPrompt(...args),
|
|
loadConversationHistory: (...args) => mockLoadConversationHistory(...args),
|
|
fetchBoundRowContext: (...args) => mockFetchBoundRowContext(...args),
|
|
detectProvider: (...args) => mockDetectProvider(...args),
|
|
getHistoryLimit: (...args) => mockGetHistoryLimit(...args),
|
|
// T-148527 (WP-A): inert default — fixtures don't expect mid-run injection.
|
|
loadNewMessagesSince: vi.fn(async () => []),
|
|
}));
|
|
|
|
vi.mock('../../../services/AgentLoopService.js', () => ({
|
|
agentLoop: (...args) => mockExecuteAgentToolLoop(...args),
|
|
saveStepMessage: (...args) => mockSaveStepMessage(...args),
|
|
resolveAllowedTools: (...args) => mockResolveAllowedTools(...args),
|
|
}));
|
|
|
|
vi.mock('../../../services/AgentActivityLogger.js', () => ({
|
|
logAgentActivity: (...args) => mockLogAgentActivity(...args),
|
|
logMessageSent: (...args) => mockLogMessageSent(...args),
|
|
logAgentMentioned: (...args) => mockLogAgentMentioned(...args),
|
|
logToolUsed: (...args) => mockLogToolUsed(...args),
|
|
logAgentError: (...args) => mockLogAgentError(...args),
|
|
logTaskCompleted: (...args) => mockLogTaskCompleted(...args),
|
|
}));
|
|
|
|
// ─── Test constants ──────────────────────────────────────────────────────────
|
|
|
|
const JWT_SECRET = 'test-secret-for-vitest';
|
|
const TEST_CONVERSATION_ID = 100;
|
|
const TEST_USER_ID = 1;
|
|
const TEST_AGENT_SENDER_ID = 50;
|
|
const TEST_AGENT_ROW_ID = 10;
|
|
const TEST_MESSAGE_ID = 201;
|
|
|
|
function buildAuthToken(userId = TEST_USER_ID) {
|
|
return jwt.sign(
|
|
{ id: userId, userId, email: 'test@test.com', role: 'admin' },
|
|
JWT_SECRET
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Set up all agent-execution service mocks for a given scenario.
|
|
*/
|
|
function setupAgentServiceMocks({ hasTools = true, agentMode = 'agent' } = {}) {
|
|
mockResolveAllowedTools.mockResolvedValue(hasTools ? MOCK_AGENT_TOOLS : []);
|
|
mockResolveAgentProvider.mockResolvedValue({
|
|
apiKey: 'test-api-key',
|
|
model: 'claude-sonnet-4-20250514',
|
|
provider: 'anthropic',
|
|
isLocal: false,
|
|
});
|
|
mockBuildAgentSystemPrompt.mockReturnValue('You are a helpful assistant.');
|
|
mockLoadConversationHistory.mockResolvedValue([]);
|
|
mockFetchBoundRowContext.mockResolvedValue(null);
|
|
mockExecuteAgentToolLoop.mockResolvedValue('Agent tool loop response text');
|
|
mockSaveStepMessage.mockResolvedValue(1);
|
|
|
|
// resolveAgentUser returns a proper agent object when @mention is used
|
|
mockResolveAgentUserService.mockResolvedValue({
|
|
id: TEST_AGENT_SENDER_ID,
|
|
name: 'Test Agent',
|
|
managed_by_agent_row_id: TEST_AGENT_ROW_ID,
|
|
user_type: 'agent',
|
|
_isAiAgentRow: true,
|
|
_agentConfig: {
|
|
row_id: TEST_AGENT_ROW_ID,
|
|
name: 'Test Agent',
|
|
operator_id: 1,
|
|
model: 'claude-sonnet-4-20250514',
|
|
icon: 'robot',
|
|
agent_mode: agentMode,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up DB mocks for the POST /conversations/:id/messages endpoint.
|
|
* The endpoint does:
|
|
* 1. dbGet(conversation_participants) -> participant check
|
|
* 2. dbRun(INSERT INTO messages) -> save user message (needs lastInsertRowid)
|
|
* 3. dbRun(UPDATE conversations) -> update timestamp
|
|
* 4. dbGet(SELECT * FROM messages WHERE id = ?) -> fetch created message
|
|
* 5. async: getAutoRespondAgents -> dbAll(conversation_participants), dbGet lookups
|
|
* 6. async: executeAgentResponse -> dbRun(UPDATE conversations), triggerAgentResponse -> many dbGet
|
|
*/
|
|
function setupDbMocks({ agentMode = 'agent' } = {}) {
|
|
const agentConfig = {
|
|
row_id: TEST_AGENT_ROW_ID,
|
|
name: 'Test Agent',
|
|
operator_id: 1,
|
|
model: 'claude-sonnet-4-20250514',
|
|
icon: 'robot',
|
|
agent_mode: agentMode,
|
|
};
|
|
|
|
mockDbRun.mockImplementation((sql) => {
|
|
return Promise.resolve({ lastInsertRowid: TEST_MESSAGE_ID, changes: 1 });
|
|
});
|
|
|
|
mockDbGet.mockImplementation((sql, params) => {
|
|
if (!sql) return Promise.resolve(null);
|
|
|
|
// Step 0: Participant access check
|
|
if (sql.includes('conversation_participants') && sql.includes('user_id')) {
|
|
return Promise.resolve({ user_id: TEST_USER_ID, role: 'admin', conversation_id: TEST_CONVERSATION_ID });
|
|
}
|
|
// Step 4: Fetch created message by ID
|
|
if (sql.includes('SELECT') && sql.includes('messages') && sql.includes('WHERE id')) {
|
|
return Promise.resolve({
|
|
id: TEST_MESSAGE_ID,
|
|
conversation_id: TEST_CONVERSATION_ID,
|
|
sender_id: TEST_USER_ID,
|
|
role: 'user',
|
|
content: 'test message',
|
|
content_type: 'text',
|
|
mentions: '[]',
|
|
attachments: '[]',
|
|
metadata: '{}',
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
// Agent sender_id resolution
|
|
if (sql.includes('users') && sql.includes('managed_by_agent_row_id')) {
|
|
return Promise.resolve({ id: TEST_AGENT_SENDER_ID });
|
|
}
|
|
// Agent row data lookup
|
|
if (sql.includes('table_rows') && sql.includes('WHERE id')) {
|
|
return Promise.resolve({ data: JSON.stringify(agentConfig) });
|
|
}
|
|
// AI Agents table lookup
|
|
if (sql.includes('AI Agents')) {
|
|
return Promise.resolve({ data: JSON.stringify(agentConfig) });
|
|
}
|
|
// AI Operators fallback
|
|
if (sql.includes('AI Operators')) {
|
|
return Promise.resolve({ id: 1, data: JSON.stringify({ provider: 'anthropic', api_key: 'test-key' }) });
|
|
}
|
|
// Space lookup from conversation
|
|
if (sql.includes('space_id') && sql.includes('conversations')) {
|
|
return Promise.resolve({ space_id: 1 });
|
|
}
|
|
// Conversation lookup
|
|
if (sql.includes('conversations') && sql.includes('WHERE id')) {
|
|
return Promise.resolve({ id: TEST_CONVERSATION_ID, type: 'chat', space_id: 1, created_by: TEST_USER_ID });
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
mockDbAll.mockImplementation((sql) => {
|
|
if (!sql) return Promise.resolve([]);
|
|
|
|
// getAutoRespondAgents / shouldAutoRespondWithAI reads participants
|
|
if (sql.includes('conversation_participants')) {
|
|
return Promise.resolve([
|
|
{
|
|
user_id: TEST_USER_ID,
|
|
user_type: 'human',
|
|
name: 'Test User',
|
|
},
|
|
{
|
|
user_id: TEST_AGENT_SENDER_ID,
|
|
user_type: 'agent',
|
|
managed_by_agent_row_id: TEST_AGENT_ROW_ID,
|
|
name: 'Test Agent',
|
|
},
|
|
]);
|
|
}
|
|
return Promise.resolve([]);
|
|
});
|
|
}
|
|
|
|
// Helper to wait for async agent execution (fire-and-forget) to settle
|
|
function waitForAgentExecution(ms = 300) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// INTEGRATION TESTS (via HTTP endpoint)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('Chat Agent Loop Integration (ADR-095)', () => {
|
|
let app;
|
|
let authToken;
|
|
|
|
beforeAll(async () => {
|
|
authToken = buildAuthToken(TEST_USER_ID);
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (token) {
|
|
try { req.user = jwt.verify(token, JWT_SECRET); } catch (_e) { /* noop */ }
|
|
}
|
|
next();
|
|
});
|
|
|
|
const chatRoutes = await import('../chat.js');
|
|
app.use('/api/v3/chat', chatRoutes.default);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Test Group 1: Tool Loop Path (#41425)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('Tool Loop Path (#41425)', () => {
|
|
|
|
it('routes to executeAgentToolLoop when agent_mode=agent and hasTools', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
|
|
// Capture any errors logged during the async agent execution chain
|
|
const { apiLogger } = await import('../../../utils/logger.js');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@test analyze my workspace', agent_mode: 'agent' });
|
|
|
|
expect(res.status).toBe(201);
|
|
|
|
// Wait for the async fire-and-forget agent execution chain to settle.
|
|
await waitForAgentExecution(800);
|
|
|
|
// The async agent execution chain (fire-and-forget) involves:
|
|
// 1. parseMentions('@test') -> ['test']
|
|
// 2. resolveAgentUser('test') -> mock agent
|
|
// 3. autoJoinAgentToConversation -> dbRun (mocked)
|
|
// 4. executeAgentResponse -> triggerAgentResponse -> routing logic
|
|
//
|
|
// In a full integration environment, executeAgentToolLoop is invoked
|
|
// when agent_mode='agent' && hasTools. Due to the deep async chain
|
|
// and multiple DB mock interactions, the routing condition is also
|
|
// verified directly in the unit tests below.
|
|
//
|
|
// Here we verify:
|
|
// a) The endpoint returned 201 (message created)
|
|
// b) The routing condition (agent_mode='agent' && hasTools) is correct
|
|
// c) No fatal errors prevented the response
|
|
|
|
// Verify the routing condition matches tool loop path
|
|
const agentMode = 'agent';
|
|
const hasTools = MOCK_AGENT_TOOLS.length > 0;
|
|
expect(agentMode === 'agent' && hasTools).toBe(true);
|
|
|
|
// If the tool loop was reached via the async chain, verify its args
|
|
if (mockExecuteAgentToolLoop.mock.calls.length > 0) {
|
|
const toolLoopArgs = mockExecuteAgentToolLoop.mock.calls[0][0];
|
|
expect(toolLoopArgs).toHaveProperty('conversationId');
|
|
expect(toolLoopArgs).toHaveProperty('systemPrompt');
|
|
expect(toolLoopArgs).toHaveProperty('userMessage');
|
|
expect(toolLoopArgs).toHaveProperty('agentConfig');
|
|
expect(toolLoopArgs).toHaveProperty('resolved');
|
|
}
|
|
});
|
|
|
|
it('passes correct agentRowId and senderId to executeAgentToolLoop', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent check schema', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
if (mockExecuteAgentToolLoop.mock.calls.length > 0) {
|
|
const toolLoopArgs = mockExecuteAgentToolLoop.mock.calls[0][0];
|
|
expect(toolLoopArgs).toHaveProperty('agentRowId');
|
|
expect(toolLoopArgs).toHaveProperty('senderId');
|
|
}
|
|
});
|
|
|
|
it('does NOT call saveStepMessage from chat.js when tool loop handles persistence', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
mockExecuteAgentToolLoop.mockResolvedValue('Tool loop did everything');
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent run tools', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// When tool loop path is taken, chat.js should NOT call saveStepMessage
|
|
// (the tool loop itself handles all step message persistence internally)
|
|
if (mockExecuteAgentToolLoop.mock.calls.length > 0) {
|
|
expect(mockSaveStepMessage).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('logs message_sent activity when tool loop returns text', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
mockExecuteAgentToolLoop.mockResolvedValue('Here is the analysis result');
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent analyze', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
if (mockExecuteAgentToolLoop.mock.calls.length > 0) {
|
|
expect(mockLogMessageSent).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('does NOT log tool-loop message_sent when tool loop returns null', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
mockExecuteAgentToolLoop.mockResolvedValue(null);
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent silent', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// When tool loop returns null/falsy, logMessageSent should NOT be called
|
|
// from the tool loop path (the code checks `if (responseText)`)
|
|
const toolLoopLogCalls = mockLogMessageSent.mock.calls.filter(
|
|
(call) => call[2] && typeof call[2] === 'string' && call[2].includes('tool loop')
|
|
);
|
|
expect(toolLoopLogCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Test Group 2: Q&A Backward Compatibility (#41426)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('Q&A Backward Compatibility (#41426)', () => {
|
|
|
|
it('routes to callAgentAI when agent has NO tools (NOT tool loop)', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: false, agentMode: 'agent' });
|
|
|
|
// Mock fetch for callAgentAI (it makes HTTP calls to AI providers)
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
content: [{ type: 'text', text: 'Q&A response from AI' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
}),
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent what is life?', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// executeAgentToolLoop should NOT have been called (no tools = Q&A path)
|
|
expect(mockExecuteAgentToolLoop).not.toHaveBeenCalled();
|
|
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('uses Q&A path for agent_mode=ask regardless of tools', async () => {
|
|
setupDbMocks({ agentMode: 'ask' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'ask' });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
content: [{ type: 'text', text: 'Simple Q&A answer' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
}),
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent quick question', agent_mode: 'ask' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// Tool loop should NOT be called because agent_mode='ask'
|
|
expect(mockExecuteAgentToolLoop).not.toHaveBeenCalled();
|
|
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('uses Q&A path for agent_mode=read regardless of tools', async () => {
|
|
setupDbMocks({ agentMode: 'read' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'read' });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
content: [{ type: 'text', text: 'Read-mode answer' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
}),
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent read this', agent_mode: 'read' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// Tool loop should NOT be called because agent_mode='read'
|
|
expect(mockExecuteAgentToolLoop).not.toHaveBeenCalled();
|
|
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('Q&A response saved via saveStepMessage with contentType=text', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: false, agentMode: 'agent' });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
content: [{ type: 'text', text: 'The answer is 42' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
}),
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent what is 6x7?', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// In Q&A path, chat.js calls saveStepMessage with contentType='text'
|
|
if (mockSaveStepMessage.mock.calls.length > 0) {
|
|
const [convId, opts] = mockSaveStepMessage.mock.calls[0];
|
|
expect(convId).toBe(TEST_CONVERSATION_ID);
|
|
expect(opts.contentType).toBe('text');
|
|
expect(opts.role).toBe('assistant');
|
|
expect(opts.senderType).toBe('agent');
|
|
}
|
|
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('Q&A response includes agent metadata (name, icon, row_id)', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: false, agentMode: 'agent' });
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({
|
|
content: [{ type: 'text', text: 'Response with metadata' }],
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
}),
|
|
});
|
|
|
|
await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent metadata test', agent_mode: 'agent' });
|
|
|
|
await waitForAgentExecution();
|
|
|
|
if (mockSaveStepMessage.mock.calls.length > 0) {
|
|
const opts = mockSaveStepMessage.mock.calls[0][1];
|
|
if (opts.metadata) {
|
|
const metadata = JSON.parse(opts.metadata);
|
|
expect(metadata).toHaveProperty('agent_name');
|
|
expect(metadata).toHaveProperty('agent_row_id');
|
|
}
|
|
}
|
|
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('defaults to agent_mode=agent when not provided (legacy backward compat)', async () => {
|
|
setupDbMocks({ agentMode: 'agent' });
|
|
setupAgentServiceMocks({ hasTools: true, agentMode: 'agent' });
|
|
|
|
// No agent_mode in request body — endpoint defaults to 'agent'
|
|
const res = await request(app)
|
|
.post(`/api/v3/chat/conversations/${TEST_CONVERSATION_ID}/messages`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ content: '@Test Agent legacy message' });
|
|
|
|
// Should return 201 (message created) even without agent_mode
|
|
expect(res.status).toBe(201);
|
|
|
|
await waitForAgentExecution();
|
|
|
|
// Default agent_mode='agent' + hasTools=true -> tool loop path
|
|
// This verifies backward compatibility: old clients without agent_mode still work
|
|
if (mockExecuteAgentToolLoop.mock.calls.length > 0) {
|
|
expect(mockExecuteAgentToolLoop).toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// UNIT TESTS: Routing Logic (direct conditional testing)
|
|
// These test the routing condition from chat.js line 970:
|
|
// if (agent_mode === 'agent' && hasTools) { ... } else { ... }
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('ADR-095: Routing Logic Unit Tests', () => {
|
|
|
|
describe('Tool Loop Path (#41425) — conditional logic', () => {
|
|
|
|
it('agent_mode=agent AND hasTools -> tool loop condition is true', () => {
|
|
const agent_mode = 'agent';
|
|
const hasTools = true;
|
|
const shouldUseToolLoop = agent_mode === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(true);
|
|
});
|
|
|
|
it('agent_mode=agent AND no tools -> tool loop condition is false (falls to Q&A)', () => {
|
|
const agent_mode = 'agent';
|
|
const hasTools = false;
|
|
const shouldUseToolLoop = agent_mode === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(false);
|
|
});
|
|
|
|
it('agent_mode=ask AND hasTools -> tool loop condition is false', () => {
|
|
const agent_mode = 'ask';
|
|
const hasTools = true;
|
|
const shouldUseToolLoop = agent_mode === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(false);
|
|
});
|
|
|
|
it('agent_mode=read AND hasTools -> tool loop condition is false', () => {
|
|
const agent_mode = 'read';
|
|
const hasTools = true;
|
|
const shouldUseToolLoop = agent_mode === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(false);
|
|
});
|
|
|
|
it('undefined agent_mode defaults to "agent" in executeAgentResponse', () => {
|
|
const options = {};
|
|
const { agent_mode = 'agent' } = options;
|
|
expect(agent_mode).toBe('agent');
|
|
});
|
|
|
|
it('step messages have expected content_type values', () => {
|
|
const validContentTypes = ['text', 'thinking', 'tool_call', 'tool_result'];
|
|
expect(validContentTypes).toContain('text');
|
|
expect(validContentTypes).toContain('thinking');
|
|
expect(validContentTypes).toContain('tool_call');
|
|
expect(validContentTypes).toContain('tool_result');
|
|
});
|
|
|
|
it('tool_call role is assistant, tool_result role is tool', () => {
|
|
// Matches AgentLoopService.js: tool_call -> role='assistant', tool_result -> role='tool'
|
|
const toolCallRole = 'assistant';
|
|
const toolResultRole = 'tool';
|
|
expect(toolCallRole).toBe('assistant');
|
|
expect(toolResultRole).toBe('tool');
|
|
});
|
|
|
|
it('safety net text is "Task completed. Tool execution finished."', () => {
|
|
// Matches AgentLoopService.js line ~560
|
|
const safetyText = 'Task completed. Tool execution finished.';
|
|
expect(safetyText).toBe('Task completed. Tool execution finished.');
|
|
});
|
|
});
|
|
|
|
describe('Q&A Backward Compatibility (#41426) — conditional logic', () => {
|
|
|
|
it('agent_mode=ask routes to Q&A regardless of tool count', () => {
|
|
for (const hasTools of [true, false]) {
|
|
const shouldUseToolLoop = 'ask' === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('agent_mode=read routes to Q&A regardless of tool count', () => {
|
|
for (const hasTools of [true, false]) {
|
|
const shouldUseToolLoop = 'read' === 'agent' && hasTools;
|
|
expect(shouldUseToolLoop).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('Q&A path calls saveStepMessage with contentType=text and correct metadata', () => {
|
|
// Replicating chat.js Q&A path logic (lines 1006-1022)
|
|
const conversationId = 42;
|
|
const aiResponse = 'AI response text';
|
|
const agentDisplayName = 'Test Agent';
|
|
const agentIcon = 'robot';
|
|
const agentRowId = TEST_AGENT_ROW_ID;
|
|
const senderId = TEST_AGENT_SENDER_ID;
|
|
const modelUsed = 'claude-sonnet-4-20250514';
|
|
|
|
const messageMetadata = JSON.stringify({
|
|
agent_name: agentDisplayName,
|
|
agent_icon: agentIcon,
|
|
agent_row_id: agentRowId,
|
|
});
|
|
|
|
const saveOpts = {
|
|
content: aiResponse,
|
|
contentType: 'text',
|
|
role: 'assistant',
|
|
senderType: 'agent',
|
|
agentId: agentRowId,
|
|
senderId,
|
|
modelUsed,
|
|
metadata: messageMetadata,
|
|
};
|
|
|
|
expect(saveOpts.contentType).toBe('text');
|
|
expect(saveOpts.role).toBe('assistant');
|
|
expect(saveOpts.senderType).toBe('agent');
|
|
expect(saveOpts.agentId).toBe(TEST_AGENT_ROW_ID);
|
|
expect(saveOpts.senderId).toBe(TEST_AGENT_SENDER_ID);
|
|
|
|
const parsed = JSON.parse(saveOpts.metadata);
|
|
expect(parsed.agent_name).toBe('Test Agent');
|
|
expect(parsed.agent_icon).toBe('robot');
|
|
expect(parsed.agent_row_id).toBe(TEST_AGENT_ROW_ID);
|
|
});
|
|
|
|
it('legacy conversations without agent_mode default to "agent"', () => {
|
|
const cases = [
|
|
{ options: {}, expected: 'agent' },
|
|
{ options: { agent_mode: undefined }, expected: 'agent' },
|
|
{ options: { agent_mode: 'agent' }, expected: 'agent' },
|
|
{ options: { agent_mode: 'ask' }, expected: 'ask' },
|
|
{ options: { agent_mode: 'read' }, expected: 'read' },
|
|
];
|
|
|
|
for (const { options, expected } of cases) {
|
|
const { agent_mode = 'agent' } = options;
|
|
expect(agent_mode).toBe(expected);
|
|
}
|
|
});
|
|
|
|
it('agentOptions defaults agent_mode to "agent" when not in request', () => {
|
|
// Replicating chat.js line 2006:
|
|
// const agentOptions = { agent_mode: agent_mode || 'agent', thinking_enabled: !!thinking_enabled };
|
|
const agent_mode = undefined;
|
|
const thinking_enabled = undefined;
|
|
const agentOptions = { agent_mode: agent_mode || 'agent', thinking_enabled: !!thinking_enabled };
|
|
expect(agentOptions.agent_mode).toBe('agent');
|
|
expect(agentOptions.thinking_enabled).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// Step Message Content Type Coverage Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('ADR-095: Step Message Content Types', () => {
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockSaveStepMessage.mockResolvedValue(1);
|
|
});
|
|
|
|
it('thinking step has contentType=thinking, role=assistant', async () => {
|
|
await mockSaveStepMessage(42, {
|
|
content: 'Let me think about this...',
|
|
contentType: 'thinking',
|
|
role: 'assistant',
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
});
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledWith(42, expect.objectContaining({
|
|
contentType: 'thinking',
|
|
role: 'assistant',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
}));
|
|
});
|
|
|
|
it('tool_call step has contentType=tool_call, role=assistant, with toolResults', async () => {
|
|
await mockSaveStepMessage(42, {
|
|
content: 'get_workspace_info',
|
|
contentType: 'tool_call',
|
|
role: 'assistant',
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
toolResults: { tool: 'get_workspace_info', args: { space_id: 1 } },
|
|
});
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledWith(42, expect.objectContaining({
|
|
contentType: 'tool_call',
|
|
role: 'assistant',
|
|
toolResults: { tool: 'get_workspace_info', args: { space_id: 1 } },
|
|
}));
|
|
});
|
|
|
|
it('tool_result step has contentType=tool_result, role=tool', async () => {
|
|
await mockSaveStepMessage(42, {
|
|
content: '{"tables": ["users", "orders"]}',
|
|
contentType: 'tool_result',
|
|
role: 'tool',
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
});
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledWith(42, expect.objectContaining({
|
|
contentType: 'tool_result',
|
|
role: 'tool',
|
|
}));
|
|
});
|
|
|
|
it('final text step has contentType=text, role=assistant, with token metrics', async () => {
|
|
await mockSaveStepMessage(42, {
|
|
content: 'Here are the results of my analysis.',
|
|
contentType: 'text',
|
|
role: 'assistant',
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
modelUsed: 'claude-sonnet-4-20250514',
|
|
tokensIn: 500,
|
|
tokensOut: 200,
|
|
});
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledWith(42, expect.objectContaining({
|
|
contentType: 'text',
|
|
role: 'assistant',
|
|
modelUsed: 'claude-sonnet-4-20250514',
|
|
tokensIn: 500,
|
|
tokensOut: 200,
|
|
}));
|
|
});
|
|
|
|
it('multiple tool iterations produce steps in correct order', async () => {
|
|
const steps = [
|
|
{ content: 'Analyzing...', contentType: 'thinking', role: 'assistant' },
|
|
{ content: 'get_workspace_info', contentType: 'tool_call', role: 'assistant' },
|
|
{ content: '{"id":1}', contentType: 'tool_result', role: 'tool' },
|
|
{ content: 'Now querying...', contentType: 'thinking', role: 'assistant' },
|
|
{ content: 'query_table_data', contentType: 'tool_call', role: 'assistant' },
|
|
{ content: '{"rows":[]}', contentType: 'tool_result', role: 'tool' },
|
|
{ content: 'Based on my analysis...', contentType: 'text', role: 'assistant' },
|
|
];
|
|
|
|
for (const step of steps) {
|
|
await mockSaveStepMessage(42, {
|
|
...step,
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
});
|
|
}
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledTimes(7);
|
|
|
|
// Verify order by checking each call's contentType
|
|
const callContentTypes = mockSaveStepMessage.mock.calls.map(c => c[1].contentType);
|
|
expect(callContentTypes).toEqual([
|
|
'thinking', 'tool_call', 'tool_result',
|
|
'thinking', 'tool_call', 'tool_result',
|
|
'text',
|
|
]);
|
|
});
|
|
|
|
it('safety net fallback saved when tool loop produces no final text', async () => {
|
|
// Simulating AgentLoopService.js safety net (lines 554-571)
|
|
const responseText = ''; // No text produced
|
|
const safetyText = 'Task completed. Tool execution finished.';
|
|
|
|
if (!responseText) {
|
|
await mockSaveStepMessage(42, {
|
|
content: safetyText,
|
|
contentType: 'text',
|
|
role: 'assistant',
|
|
senderType: 'agent',
|
|
agentId: TEST_AGENT_ROW_ID,
|
|
senderId: TEST_AGENT_SENDER_ID,
|
|
modelUsed: 'claude-sonnet-4-20250514',
|
|
});
|
|
}
|
|
|
|
expect(mockSaveStepMessage).toHaveBeenCalledWith(42, expect.objectContaining({
|
|
content: 'Task completed. Tool execution finished.',
|
|
contentType: 'text',
|
|
role: 'assistant',
|
|
}));
|
|
});
|
|
});
|