Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
613 lines
20 KiB
JavaScript
613 lines
20 KiB
JavaScript
/**
|
|
* ADR-110: Conversation Steps API Tests
|
|
*
|
|
* Tests for:
|
|
* - GET /api/v3/chat/conversations/:id/steps (list steps)
|
|
* - GET /api/v3/chat/conversations/:id/steps/:stepId (step detail)
|
|
* - Deep merge of context_settings in PUT /ai/agents/:agentId
|
|
*
|
|
* Uses a minimal Express app with isolated route handlers to avoid
|
|
* interference from the full chat router's many middleware and routes.
|
|
*
|
|
* @see backend/routes/v3/chat.js
|
|
* @see ADR-110: Hierarchical Smart Context
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
|
|
|
// ─── Mock DB ─────────────────────────────────────────────────
|
|
|
|
const mockDbGet = vi.fn();
|
|
const mockDbAll = vi.fn();
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: (...args) => mockDbGet(...args),
|
|
dbAll: (...args) => mockDbAll(...args),
|
|
dbRun: vi.fn(),
|
|
isPostgres: vi.fn(() => false),
|
|
safeJsonParse: (str, def) => {
|
|
if (str === null || str === undefined) return def;
|
|
if (typeof str === 'object') return str;
|
|
try { return JSON.parse(str); } catch { return def ?? null; }
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => ({
|
|
apiLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { isPostgres, safeJsonParse } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
|
|
// ─── Re-create the route handlers in isolation ──────────────
|
|
// This avoids importing the full chat.js which has dozens of routes,
|
|
// module-level side effects, and complex mock requirements.
|
|
|
|
function success(res, data) {
|
|
return res.json({ success: true, data });
|
|
}
|
|
function badRequest(res, message) {
|
|
return res.status(400).json({ success: false, error: message });
|
|
}
|
|
function notFound(res, entity) {
|
|
return res.status(404).json({ success: false, error: `${entity} not found` });
|
|
}
|
|
function error(res, code, message, status = 500) {
|
|
return res.status(status).json({ success: false, error: message, code });
|
|
}
|
|
|
|
let app;
|
|
|
|
beforeAll(() => {
|
|
const router = express.Router();
|
|
|
|
// Minimal auth middleware
|
|
router.use((req, _res, next) => {
|
|
req.user = { id: 1, userId: 1 };
|
|
next();
|
|
});
|
|
|
|
// ── Steps list endpoint (copied from chat.js) ──
|
|
router.get('/conversations/:id/steps', async (req, res) => {
|
|
try {
|
|
const conversationId = parseInt(req.params.id);
|
|
if (!conversationId || isNaN(conversationId)) {
|
|
return badRequest(res, 'Invalid conversation ID');
|
|
}
|
|
|
|
const allowedTypes = ['thinking', 'tool_call', 'tool_result'];
|
|
const typeParam = req.query.type || 'tool_call,tool_result,thinking';
|
|
const requestedTypes = typeParam.split(',').map(t => t.trim()).filter(t => allowedTypes.includes(t));
|
|
if (requestedTypes.length === 0) {
|
|
return badRequest(res, `Invalid type filter. Allowed: ${allowedTypes.join(', ')}`);
|
|
}
|
|
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 20, 1), 100);
|
|
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
|
const offset = (page - 1) * limit;
|
|
|
|
const conversation = await mockDbGet(
|
|
`SELECT id FROM conversations WHERE id = ?`,
|
|
[conversationId]
|
|
);
|
|
if (!conversation) {
|
|
return notFound(res, 'Conversation');
|
|
}
|
|
|
|
const countRow = await mockDbGet(
|
|
`SELECT COUNT(*) as total FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL)`,
|
|
[conversationId, ...requestedTypes]
|
|
);
|
|
const total = countRow?.total || 0;
|
|
|
|
const steps = await mockDbAll(
|
|
`SELECT id, content_type, content, tool_results, created_at FROM messages WHERE conversation_id = ? AND content_type IN (${requestedTypes.map(() => '?').join(', ')}) AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY created_at ASC LIMIT ? OFFSET ?`,
|
|
[conversationId, ...requestedTypes, limit, offset]
|
|
);
|
|
|
|
const PREVIEW_CHARS = 100;
|
|
const formattedSteps = steps.map(s => {
|
|
let toolName = null;
|
|
if (s.tool_results) {
|
|
const toolData = safeJsonParse(s.tool_results);
|
|
toolName = toolData?.tool || toolData?.name || null;
|
|
}
|
|
if (!toolName && s.content_type === 'tool_call' && s.content) {
|
|
const parsed = safeJsonParse(s.content);
|
|
toolName = parsed?.tool || parsed?.name || null;
|
|
if (!toolName && typeof s.content === 'string' && s.content.length < 100 && !s.content.includes(' ')) {
|
|
toolName = s.content;
|
|
}
|
|
}
|
|
return {
|
|
id: s.id,
|
|
type: s.content_type,
|
|
tool_name: toolName,
|
|
preview: s.content ? s.content.substring(0, PREVIEW_CHARS) : '',
|
|
timestamp: s.created_at,
|
|
};
|
|
});
|
|
|
|
const hasMore = offset + limit < total;
|
|
|
|
return success(res, {
|
|
steps: formattedSteps,
|
|
hasMore,
|
|
nextCursor: hasMore ? page + 1 : null,
|
|
total,
|
|
});
|
|
} catch (err) {
|
|
return error(res, 'STEPS_LIST_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// ── Step detail endpoint (copied from chat.js) ──
|
|
router.get('/conversations/:id/steps/:stepId', async (req, res) => {
|
|
try {
|
|
const conversationId = parseInt(req.params.id);
|
|
const stepId = parseInt(req.params.stepId);
|
|
if (!conversationId || isNaN(conversationId) || !stepId || isNaN(stepId)) {
|
|
return badRequest(res, 'Invalid conversation or step ID');
|
|
}
|
|
|
|
const step = await mockDbGet(
|
|
`SELECT id, conversation_id, content_type, role, content, tool_results, agent_id, model_used, tokens_in, tokens_out, created_at FROM messages WHERE id = ? AND conversation_id = ? AND content_type IN ('thinking', 'tool_call', 'tool_result') AND (is_deleted = 0 OR is_deleted IS NULL)`,
|
|
[stepId, conversationId]
|
|
);
|
|
|
|
if (!step) {
|
|
return notFound(res, 'Step');
|
|
}
|
|
|
|
const toolData = step.tool_results ? safeJsonParse(step.tool_results) : null;
|
|
|
|
return success(res, {
|
|
id: step.id,
|
|
type: step.content_type,
|
|
content: step.content,
|
|
tool_name: toolData?.tool || toolData?.name || null,
|
|
tool_args: toolData?.args || null,
|
|
metadata: {
|
|
role: step.role,
|
|
agent_id: step.agent_id,
|
|
model: step.model_used,
|
|
tokens: (step.tokens_in || step.tokens_out)
|
|
? { in: step.tokens_in, out: step.tokens_out }
|
|
: null,
|
|
result: toolData?.result || null,
|
|
},
|
|
conversation_id: step.conversation_id,
|
|
created_at: step.created_at,
|
|
});
|
|
} catch (err) {
|
|
return error(res, 'STEP_DETAIL_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use('/api/v3/chat', router);
|
|
});
|
|
|
|
describe('ADR-110: Conversation Steps API', () => {
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ─── GET /conversations/:id/steps ─────────────────────────
|
|
|
|
describe('GET /conversations/:id/steps', () => {
|
|
|
|
it('should return steps for a valid conversation', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 }) // conversation check
|
|
.mockResolvedValueOnce({ total: 2 }); // count
|
|
|
|
mockDbAll.mockResolvedValueOnce([
|
|
{
|
|
id: 101,
|
|
content_type: 'tool_call',
|
|
content: 'search_records',
|
|
tool_results: JSON.stringify({ tool: 'search_records', args: { query: 'test' } }),
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
},
|
|
{
|
|
id: 102,
|
|
content_type: 'tool_result',
|
|
content: 'Found 5 records matching your query in the database',
|
|
tool_results: JSON.stringify({ tool: 'search_records', result: 'ok' }),
|
|
created_at: '2026-03-01T10:00:01Z',
|
|
},
|
|
]);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps')
|
|
.expect(200);
|
|
|
|
expect(res.body.data.steps).toHaveLength(2);
|
|
expect(res.body.data.total).toBe(2);
|
|
expect(res.body.data.hasMore).toBe(false);
|
|
expect(res.body.data.nextCursor).toBeNull();
|
|
|
|
const step1 = res.body.data.steps[0];
|
|
expect(step1.id).toBe(101);
|
|
expect(step1.type).toBe('tool_call');
|
|
expect(step1.tool_name).toBe('search_records');
|
|
expect(step1.preview).toBeDefined();
|
|
expect(step1.timestamp).toBeDefined();
|
|
});
|
|
|
|
it('should return 404 for non-existent conversation', async () => {
|
|
mockDbGet.mockResolvedValueOnce(null);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/999/steps')
|
|
.expect(404);
|
|
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should filter by type parameter', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 })
|
|
.mockResolvedValueOnce({ total: 1 });
|
|
|
|
mockDbAll.mockResolvedValueOnce([
|
|
{
|
|
id: 101,
|
|
content_type: 'thinking',
|
|
content: 'Let me think about this',
|
|
tool_results: null,
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
},
|
|
]);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps?type=thinking')
|
|
.expect(200);
|
|
|
|
expect(res.body.data.steps).toHaveLength(1);
|
|
expect(res.body.data.steps[0].type).toBe('thinking');
|
|
});
|
|
|
|
it('should reject invalid type parameter', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps?type=invalid_type')
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should handle pagination correctly', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 })
|
|
.mockResolvedValueOnce({ total: 25 });
|
|
|
|
mockDbAll.mockResolvedValueOnce(
|
|
Array.from({ length: 10 }, (_, i) => ({
|
|
id: i + 1,
|
|
content_type: 'tool_call',
|
|
content: `tool_${i}`,
|
|
tool_results: null,
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
}))
|
|
);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps?limit=10&page=1')
|
|
.expect(200);
|
|
|
|
expect(res.body.data.steps).toHaveLength(10);
|
|
expect(res.body.data.hasMore).toBe(true);
|
|
expect(res.body.data.nextCursor).toBe(2);
|
|
expect(res.body.data.total).toBe(25);
|
|
});
|
|
|
|
it('should return preview truncated to 100 chars', async () => {
|
|
const longContent = 'x'.repeat(200);
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 })
|
|
.mockResolvedValueOnce({ total: 1 });
|
|
|
|
mockDbAll.mockResolvedValueOnce([
|
|
{
|
|
id: 101,
|
|
content_type: 'tool_result',
|
|
content: longContent,
|
|
tool_results: null,
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
},
|
|
]);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps')
|
|
.expect(200);
|
|
|
|
expect(res.body.data.steps[0].preview).toHaveLength(100);
|
|
});
|
|
|
|
it('should handle invalid conversation ID', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/abc/steps')
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should cap limit at 100', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 })
|
|
.mockResolvedValueOnce({ total: 0 });
|
|
mockDbAll.mockResolvedValueOnce([]);
|
|
|
|
await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps?limit=500')
|
|
.expect(200);
|
|
|
|
// Verify LIMIT param is capped
|
|
const params = mockDbAll.mock.calls[0][1];
|
|
const limitParam = params[params.length - 2];
|
|
expect(limitParam).toBeLessThanOrEqual(100);
|
|
});
|
|
|
|
it('should default to all step types when no type param', async () => {
|
|
mockDbGet
|
|
.mockResolvedValueOnce({ id: 42 })
|
|
.mockResolvedValueOnce({ total: 0 });
|
|
mockDbAll.mockResolvedValueOnce([]);
|
|
|
|
await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps')
|
|
.expect(200);
|
|
|
|
// Verify all three types are in the params
|
|
const params = mockDbAll.mock.calls[0][1];
|
|
expect(params).toContain('tool_call');
|
|
expect(params).toContain('tool_result');
|
|
expect(params).toContain('thinking');
|
|
});
|
|
});
|
|
|
|
// ─── GET /conversations/:id/steps/:stepId ──────────────────
|
|
|
|
describe('GET /conversations/:id/steps/:stepId', () => {
|
|
|
|
it('should return full step detail', async () => {
|
|
mockDbGet.mockResolvedValueOnce({
|
|
id: 101,
|
|
conversation_id: 42,
|
|
content_type: 'tool_result',
|
|
role: 'assistant',
|
|
content: 'Full detailed result content here',
|
|
tool_results: JSON.stringify({
|
|
tool: 'search_records',
|
|
args: { query: 'open tasks', table_id: 5 },
|
|
result: { records: [{ id: 1, name: 'Task 1' }] },
|
|
}),
|
|
agent_id: 7,
|
|
model_used: 'gpt-4-turbo',
|
|
tokens_in: 500,
|
|
tokens_out: 200,
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps/101')
|
|
.expect(200);
|
|
|
|
const data = res.body.data;
|
|
expect(data.id).toBe(101);
|
|
expect(data.type).toBe('tool_result');
|
|
expect(data.content).toBe('Full detailed result content here');
|
|
expect(data.tool_name).toBe('search_records');
|
|
expect(data.tool_args).toEqual({ query: 'open tasks', table_id: 5 });
|
|
expect(data.metadata.model).toBe('gpt-4-turbo');
|
|
expect(data.metadata.tokens).toEqual({ in: 500, out: 200 });
|
|
expect(data.metadata.result).toBeDefined();
|
|
expect(data.conversation_id).toBe(42);
|
|
expect(data.created_at).toBe('2026-03-01T10:00:00Z');
|
|
});
|
|
|
|
it('should return 404 for non-existent step', async () => {
|
|
mockDbGet.mockResolvedValueOnce(null);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps/999')
|
|
.expect(404);
|
|
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should return step without tokens when not tracked', async () => {
|
|
mockDbGet.mockResolvedValueOnce({
|
|
id: 101,
|
|
conversation_id: 42,
|
|
content_type: 'thinking',
|
|
role: 'assistant',
|
|
content: 'Some thinking process',
|
|
tool_results: null,
|
|
agent_id: null,
|
|
model_used: null,
|
|
tokens_in: null,
|
|
tokens_out: null,
|
|
created_at: '2026-03-01T10:00:00Z',
|
|
});
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps/101')
|
|
.expect(200);
|
|
|
|
const data = res.body.data;
|
|
expect(data.type).toBe('thinking');
|
|
expect(data.tool_name).toBeNull();
|
|
expect(data.tool_args).toBeNull();
|
|
expect(data.metadata.tokens).toBeNull();
|
|
});
|
|
|
|
it('should handle invalid step ID', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps/abc')
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBeDefined();
|
|
});
|
|
|
|
it('should scope step to conversation_id for security', async () => {
|
|
mockDbGet.mockResolvedValueOnce(null); // step not found for this conversation
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/chat/conversations/42/steps/101')
|
|
.expect(404);
|
|
|
|
// Verify query includes both stepId AND conversationId
|
|
const queryArgs = mockDbGet.mock.calls[0][1];
|
|
expect(queryArgs).toContain(101); // stepId
|
|
expect(queryArgs).toContain(42); // conversationId
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Deep Merge context_settings Tests ────────────────────
|
|
|
|
describe('ADR-110: Deep merge context_settings (ai-agents PUT)', () => {
|
|
|
|
/**
|
|
* Since the deepMergeContextSettings function is private to ai-agents.js,
|
|
* we test the logic by replicating it here (same algorithm).
|
|
* This validates the merge behavior documented in ADR-110 AC12.
|
|
*/
|
|
function deepMergeContextSettings(existing, incoming) {
|
|
const merged = { ...existing };
|
|
for (const key of Object.keys(incoming)) {
|
|
if (
|
|
key === 'context_levels' &&
|
|
typeof incoming[key] === 'object' && incoming[key] !== null &&
|
|
typeof merged[key] === 'object' && merged[key] !== null
|
|
) {
|
|
merged[key] = { ...merged[key], ...incoming[key] };
|
|
} else {
|
|
merged[key] = incoming[key];
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
it('should deep merge context_levels without wiping sibling fields', () => {
|
|
const existing = {
|
|
max_history: 100,
|
|
context_levels: {
|
|
thinking: false,
|
|
thinking_preview_chars: 200,
|
|
tool_summaries: false,
|
|
tool_preview_chars: 100,
|
|
full_tool_results: false,
|
|
},
|
|
};
|
|
|
|
const incoming = {
|
|
context_levels: {
|
|
thinking: true,
|
|
},
|
|
};
|
|
|
|
const result = deepMergeContextSettings(existing, incoming);
|
|
|
|
expect(result.max_history).toBe(100);
|
|
expect(result.context_levels.thinking).toBe(true);
|
|
expect(result.context_levels.thinking_preview_chars).toBe(200);
|
|
expect(result.context_levels.tool_summaries).toBe(false);
|
|
expect(result.context_levels.tool_preview_chars).toBe(100);
|
|
expect(result.context_levels.full_tool_results).toBe(false);
|
|
});
|
|
|
|
it('should overwrite top-level keys normally', () => {
|
|
const existing = { max_history: 100, some_flag: true };
|
|
const incoming = { max_history: 50 };
|
|
|
|
const result = deepMergeContextSettings(existing, incoming);
|
|
|
|
expect(result.max_history).toBe(50);
|
|
expect(result.some_flag).toBe(true);
|
|
});
|
|
|
|
it('should create context_levels when it does not exist yet', () => {
|
|
const existing = { max_history: 100 };
|
|
const incoming = {
|
|
context_levels: { thinking: true, tool_summaries: true },
|
|
};
|
|
|
|
const result = deepMergeContextSettings(existing, incoming);
|
|
|
|
expect(result.context_levels).toEqual({ thinking: true, tool_summaries: true });
|
|
expect(result.max_history).toBe(100);
|
|
});
|
|
|
|
it('should handle empty incoming gracefully', () => {
|
|
const existing = {
|
|
max_history: 100,
|
|
context_levels: { thinking: true },
|
|
};
|
|
|
|
const result = deepMergeContextSettings(existing, {});
|
|
|
|
expect(result).toEqual(existing);
|
|
});
|
|
|
|
it('should handle empty existing gracefully', () => {
|
|
const incoming = {
|
|
context_levels: { thinking: true },
|
|
};
|
|
|
|
const result = deepMergeContextSettings({}, incoming);
|
|
|
|
expect(result.context_levels).toEqual({ thinking: true });
|
|
});
|
|
|
|
it('should handle null context_levels in incoming (overwrite)', () => {
|
|
const existing = {
|
|
context_levels: { thinking: true },
|
|
};
|
|
const incoming = { context_levels: null };
|
|
|
|
const result = deepMergeContextSettings(existing, incoming);
|
|
|
|
expect(result.context_levels).toBeNull();
|
|
});
|
|
|
|
it('should preserve multiple nested fields during partial update', () => {
|
|
const existing = {
|
|
max_history: 75,
|
|
context_levels: {
|
|
thinking: true,
|
|
thinking_preview_chars: 300,
|
|
tool_summaries: true,
|
|
tool_preview_chars: 150,
|
|
full_tool_results: false,
|
|
},
|
|
};
|
|
|
|
const incoming = {
|
|
context_levels: {
|
|
full_tool_results: true,
|
|
tool_preview_chars: 200,
|
|
},
|
|
};
|
|
|
|
const result = deepMergeContextSettings(existing, incoming);
|
|
|
|
expect(result.max_history).toBe(75);
|
|
expect(result.context_levels.thinking).toBe(true);
|
|
expect(result.context_levels.thinking_preview_chars).toBe(300);
|
|
expect(result.context_levels.tool_summaries).toBe(true);
|
|
expect(result.context_levels.tool_preview_chars).toBe(200);
|
|
expect(result.context_levels.full_tool_results).toBe(true);
|
|
});
|
|
});
|