Add agent logs and stats endpoints
This commit is contained in:
parent
ef95c37984
commit
b064a3e481
4 changed files with 868 additions and 1 deletions
172
apps/web/src/app/api/agents/[id]/logs/route.ts
Normal file
172
apps/web/src/app/api/agents/[id]/logs/route.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Agent Conversation Logs Endpoint
|
||||
*
|
||||
* GET: Retrieve recent conversation activity logs for an agent
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'message' | 'tool_call' | 'tool_result' | 'conversation_start';
|
||||
conversationId: string;
|
||||
conversationSlug: string;
|
||||
role?: string;
|
||||
content?: string;
|
||||
toolName?: string;
|
||||
toolCallId?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]/logs
|
||||
* Retrieve recent conversation activity logs (accepts id or uid)
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex query logic needed for flexible log filtering
|
||||
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: idOrUid } = await context.params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '100', 10), 500);
|
||||
const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
|
||||
const conversationSlug = searchParams.get('conversation') || undefined;
|
||||
const type = searchParams.get('type') || undefined; // 'message' | 'tool_call' | 'all'
|
||||
|
||||
try {
|
||||
// Fetch agent by id or uid
|
||||
const agent = await prisma.agent.findFirst({
|
||||
where: {
|
||||
OR: [{ id: idOrUid }, { uid: idOrUid }],
|
||||
},
|
||||
select: { id: true, name: true, uid: true },
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build role filter based on type
|
||||
const roleFilter =
|
||||
type === 'tool_call'
|
||||
? { in: ['TOOL' as const] }
|
||||
: type === 'message'
|
||||
? { in: ['USER' as const, 'ASSISTANT' as const] }
|
||||
: undefined;
|
||||
|
||||
// Fetch messages with conversation info
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
conversation: {
|
||||
agentId: agent.id,
|
||||
...(conversationSlug && { slug: conversationSlug }),
|
||||
},
|
||||
...(roleFilter && { role: roleFilter }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
skip: offset,
|
||||
include: {
|
||||
conversation: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = messages.length > limit;
|
||||
const data = hasMore ? messages.slice(0, limit) : messages;
|
||||
|
||||
// Transform to log entries
|
||||
const logs: LogEntry[] = data.map((msg) => {
|
||||
const base = {
|
||||
id: msg.id,
|
||||
timestamp: msg.createdAt.toISOString(),
|
||||
conversationId: msg.conversation.id,
|
||||
conversationSlug: msg.conversation.slug,
|
||||
};
|
||||
|
||||
if (msg.role === 'TOOL') {
|
||||
return {
|
||||
...base,
|
||||
type: 'tool_result' as const,
|
||||
toolName: msg.toolName || undefined,
|
||||
toolCallId: msg.toolCallId || undefined,
|
||||
content: msg.content.slice(0, 500), // Truncate for logs view
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: 'message' as const,
|
||||
role: msg.role,
|
||||
content: msg.content.slice(0, 500), // Truncate for logs view
|
||||
inputTokens: msg.inputTokens || undefined,
|
||||
outputTokens: msg.outputTokens || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Get unique tool calls from assistant messages
|
||||
const assistantMessages = data.filter((m) => m.role === 'ASSISTANT' && m.toolCalls);
|
||||
const toolCallLogs: LogEntry[] = [];
|
||||
|
||||
for (const msg of assistantMessages) {
|
||||
if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
|
||||
for (const tc of msg.toolCalls as Array<{
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
toolName?: string;
|
||||
}>) {
|
||||
toolCallLogs.push({
|
||||
id: `${msg.id}-${tc.id || tc.toolCallId}`,
|
||||
timestamp: msg.createdAt.toISOString(),
|
||||
type: 'tool_call',
|
||||
conversationId: msg.conversation.id,
|
||||
conversationSlug: msg.conversation.slug,
|
||||
toolName: tc.name || tc.toolName,
|
||||
toolCallId: tc.id || tc.toolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and sort all logs
|
||||
const allLogs = [...logs, ...toolCallLogs].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
agent: {
|
||||
id: agent.id,
|
||||
uid: agent.uid,
|
||||
name: agent.name,
|
||||
},
|
||||
logs: allLogs.slice(0, limit),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agent logs:', error);
|
||||
return NextResponse.json({ success: false, error: 'Failed to fetch logs' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
269
apps/web/src/app/api/agents/[id]/stats/route.ts
Normal file
269
apps/web/src/app/api/agents/[id]/stats/route.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Agent Statistics Endpoint
|
||||
*
|
||||
* GET: Retrieve aggregated statistics for an agent's conversations
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 30;
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
interface AgentStats {
|
||||
agent: {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
conversations: {
|
||||
total: number;
|
||||
last24h: number;
|
||||
last7d: number;
|
||||
last30d: number;
|
||||
};
|
||||
messages: {
|
||||
total: number;
|
||||
byRole: {
|
||||
user: number;
|
||||
assistant: number;
|
||||
tool: number;
|
||||
system: number;
|
||||
};
|
||||
last24h: number;
|
||||
last7d: number;
|
||||
};
|
||||
tokens: {
|
||||
totalInput: number;
|
||||
totalOutput: number;
|
||||
total: number;
|
||||
averagePerConversation: number;
|
||||
last24h: {
|
||||
input: number;
|
||||
output: number;
|
||||
};
|
||||
last7d: {
|
||||
input: number;
|
||||
output: number;
|
||||
};
|
||||
};
|
||||
tools: {
|
||||
totalCalls: number;
|
||||
uniqueToolsUsed: number;
|
||||
topTools: Array<{
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
last24h: number;
|
||||
};
|
||||
activity: {
|
||||
firstConversation: string | null;
|
||||
lastConversation: string | null;
|
||||
averageMessagesPerConversation: number;
|
||||
longestConversation: {
|
||||
id: string;
|
||||
slug: string;
|
||||
messageCount: number;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents/[id]/stats
|
||||
* Retrieve aggregated statistics for an agent (accepts id or uid)
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
|
||||
const { id: idOrUid } = await context.params;
|
||||
|
||||
try {
|
||||
// Fetch agent by id or uid
|
||||
const agent = await prisma.agent.findFirst({
|
||||
where: {
|
||||
OR: [{ id: idOrUid }, { uid: idOrUid }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
modelId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const last30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Conversation stats
|
||||
const [totalConversations, convLast24h, convLast7d, convLast30d] = await Promise.all([
|
||||
prisma.conversation.count({ where: { agentId: agent.id } }),
|
||||
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last24h } } }),
|
||||
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last7d } } }),
|
||||
prisma.conversation.count({ where: { agentId: agent.id, createdAt: { gte: last30d } } }),
|
||||
]);
|
||||
|
||||
// Message stats
|
||||
const [totalMessages, userMessages, assistantMessages, toolMessages, msgLast24h, msgLast7d] =
|
||||
await Promise.all([
|
||||
prisma.message.count({ where: { conversation: { agentId: agent.id } } }),
|
||||
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'USER' } }),
|
||||
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'ASSISTANT' } }),
|
||||
prisma.message.count({ where: { conversation: { agentId: agent.id }, role: 'TOOL' } }),
|
||||
prisma.message.count({
|
||||
where: { conversation: { agentId: agent.id }, createdAt: { gte: last24h } },
|
||||
}),
|
||||
prisma.message.count({
|
||||
where: { conversation: { agentId: agent.id }, createdAt: { gte: last7d } },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Token stats using aggregate
|
||||
const [tokenTotals, tokensLast24h, tokensLast7d] = await Promise.all([
|
||||
prisma.message.aggregate({
|
||||
where: { conversation: { agentId: agent.id } },
|
||||
_sum: { inputTokens: true, outputTokens: true },
|
||||
}),
|
||||
prisma.message.aggregate({
|
||||
where: { conversation: { agentId: agent.id }, createdAt: { gte: last24h } },
|
||||
_sum: { inputTokens: true, outputTokens: true },
|
||||
}),
|
||||
prisma.message.aggregate({
|
||||
where: { conversation: { agentId: agent.id }, createdAt: { gte: last7d } },
|
||||
_sum: { inputTokens: true, outputTokens: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalInput = tokenTotals._sum.inputTokens || 0;
|
||||
const totalOutput = tokenTotals._sum.outputTokens || 0;
|
||||
|
||||
// Tool usage stats
|
||||
const toolMessages2 = await prisma.message.findMany({
|
||||
where: { conversation: { agentId: agent.id }, role: 'TOOL' },
|
||||
select: { toolName: true, createdAt: true },
|
||||
});
|
||||
|
||||
const toolCounts: Record<string, number> = {};
|
||||
let toolLast24h = 0;
|
||||
|
||||
for (const tm of toolMessages2) {
|
||||
if (tm.toolName) {
|
||||
toolCounts[tm.toolName] = (toolCounts[tm.toolName] || 0) + 1;
|
||||
}
|
||||
if (tm.createdAt >= last24h) {
|
||||
toolLast24h++;
|
||||
}
|
||||
}
|
||||
|
||||
const topTools = Object.entries(toolCounts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Activity stats
|
||||
const [firstConv, lastConv, longestConv] = await Promise.all([
|
||||
prisma.conversation.findFirst({
|
||||
where: { agentId: agent.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.conversation.findFirst({
|
||||
where: { agentId: agent.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.conversation.findFirst({
|
||||
where: { agentId: agent.id },
|
||||
orderBy: { messages: { _count: 'desc' } },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
_count: { select: { messages: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const stats: AgentStats = {
|
||||
agent: {
|
||||
id: agent.id,
|
||||
uid: agent.uid,
|
||||
name: agent.name,
|
||||
provider: agent.provider,
|
||||
modelId: agent.modelId,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
},
|
||||
conversations: {
|
||||
total: totalConversations,
|
||||
last24h: convLast24h,
|
||||
last7d: convLast7d,
|
||||
last30d: convLast30d,
|
||||
},
|
||||
messages: {
|
||||
total: totalMessages,
|
||||
byRole: {
|
||||
user: userMessages,
|
||||
assistant: assistantMessages,
|
||||
tool: toolMessages,
|
||||
system: 0, // System messages are inline, not stored separately
|
||||
},
|
||||
last24h: msgLast24h,
|
||||
last7d: msgLast7d,
|
||||
},
|
||||
tokens: {
|
||||
totalInput,
|
||||
totalOutput,
|
||||
total: totalInput + totalOutput,
|
||||
averagePerConversation:
|
||||
totalConversations > 0 ? Math.round((totalInput + totalOutput) / totalConversations) : 0,
|
||||
last24h: {
|
||||
input: tokensLast24h._sum.inputTokens || 0,
|
||||
output: tokensLast24h._sum.outputTokens || 0,
|
||||
},
|
||||
last7d: {
|
||||
input: tokensLast7d._sum.inputTokens || 0,
|
||||
output: tokensLast7d._sum.outputTokens || 0,
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: toolMessages,
|
||||
uniqueToolsUsed: Object.keys(toolCounts).length,
|
||||
topTools,
|
||||
last24h: toolLast24h,
|
||||
},
|
||||
activity: {
|
||||
firstConversation: firstConv?.createdAt.toISOString() || null,
|
||||
lastConversation: lastConv?.createdAt.toISOString() || null,
|
||||
averageMessagesPerConversation:
|
||||
totalConversations > 0 ? Math.round(totalMessages / totalConversations) : 0,
|
||||
longestConversation: longestConv
|
||||
? {
|
||||
id: longestConv.id,
|
||||
slug: longestConv.slug,
|
||||
messageCount: longestConv._count.messages,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agent stats:', error);
|
||||
return NextResponse.json({ success: false, error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -135,9 +135,12 @@ export async function fetchAgentByIdOrUidWithTools(
|
|||
|
||||
/**
|
||||
* Sanitize npm package name to valid tool name
|
||||
* OpenAI limits tool names to 64 characters
|
||||
*/
|
||||
function sanitizeToolName(name: string): string {
|
||||
return name.replace(/[@/]/g, '-').replace(/^-+/, '');
|
||||
const sanitized = name.replace(/[@/]/g, '-').replace(/^-+/, '');
|
||||
// Truncate to 64 chars (OpenAI limit)
|
||||
return sanitized.slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
423
apps/web/src/test/agents.test.ts
Normal file
423
apps/web/src/test/agents.test.ts
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
/**
|
||||
* Agent Endpoints Integration Tests
|
||||
*
|
||||
* Tests the agent conversation API endpoints including:
|
||||
* - Sending messages and receiving streaming responses
|
||||
* - Tool calls and execution
|
||||
* - Conversation history persistence
|
||||
* - Database state verification
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
|
||||
const TEST_AGENT_UID = 'omega'; // The test agent UID
|
||||
const TEST_CONVERSATION_SLUG = `test-conv-${Date.now()}`;
|
||||
|
||||
interface SSEEvent {
|
||||
event: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSE response into array of events
|
||||
*/
|
||||
function parseSSEResponse(text: string): SSEEvent[] {
|
||||
const events: SSEEvent[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
let currentEvent = '';
|
||||
let currentData = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6);
|
||||
if (currentEvent && currentData) {
|
||||
try {
|
||||
events.push({
|
||||
event: currentEvent,
|
||||
data: JSON.parse(currentData),
|
||||
});
|
||||
} catch {
|
||||
events.push({
|
||||
event: currentEvent,
|
||||
data: currentData,
|
||||
});
|
||||
}
|
||||
currentEvent = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('Agent Endpoints', () => {
|
||||
let testAgent: {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
provider: string;
|
||||
} | null = null;
|
||||
|
||||
let testConversationId: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Find the test agent in the database
|
||||
testAgent = await prisma.agent.findUnique({
|
||||
where: { uid: TEST_AGENT_UID },
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!testAgent) {
|
||||
console.warn(`⚠️ Test agent "${TEST_AGENT_UID}" not found. Skipping agent tests.`);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test conversation
|
||||
if (testConversationId) {
|
||||
await prisma.conversation.deleteMany({
|
||||
where: { id: testConversationId },
|
||||
});
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('POST /api/agents/[id]/conversation/[conversationId]', () => {
|
||||
it('should create a new conversation and receive a response', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Hello, what is 2 + 2?' }),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.headers.get('content-type')).toContain('text/event-stream');
|
||||
|
||||
const text = await response.text();
|
||||
const events = parseSSEResponse(text);
|
||||
|
||||
// Should have at least chunk events and a complete event
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the complete event
|
||||
const completeEvent = events.find((e) => e.event === 'complete');
|
||||
expect(completeEvent).toBeDefined();
|
||||
|
||||
if (completeEvent?.data && typeof completeEvent.data === 'object') {
|
||||
const data = completeEvent.data as { conversationId?: string };
|
||||
testConversationId = data.conversationId || null;
|
||||
}
|
||||
|
||||
// Find text chunks
|
||||
const textChunks = events.filter((e) => e.event === 'chunk');
|
||||
expect(textChunks.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify token usage was reported
|
||||
const tokenEvent = events.find((e) => e.event === 'tokens');
|
||||
expect(tokenEvent).toBeDefined();
|
||||
if (tokenEvent?.data && typeof tokenEvent.data === 'object') {
|
||||
const tokens = tokenEvent.data as { inputTokens?: number; outputTokens?: number };
|
||||
expect(tokens.inputTokens).toBeGreaterThan(0);
|
||||
expect(tokens.outputTokens).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should persist conversation in database', async () => {
|
||||
if (!testAgent || !testConversationId) {
|
||||
console.log('Skipping: No test conversation available');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = await prisma.conversation.findUnique({
|
||||
where: { id: testConversationId },
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(conversation).toBeDefined();
|
||||
expect(conversation?.agentId).toBe(testAgent.id);
|
||||
expect(conversation?.slug).toBe(TEST_CONVERSATION_SLUG);
|
||||
|
||||
// Should have at least a user message and an assistant message
|
||||
expect(conversation?.messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const userMessage = conversation?.messages.find((m) => m.role === 'USER');
|
||||
expect(userMessage?.content).toContain('2 + 2');
|
||||
|
||||
const assistantMessage = conversation?.messages.find((m) => m.role === 'ASSISTANT');
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage?.content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should maintain conversation history on follow-up messages', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'What was my previous question?' }),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const text = await response.text();
|
||||
const events = parseSSEResponse(text);
|
||||
|
||||
// Collect all text from chunks
|
||||
const textContent = events
|
||||
.filter((e) => e.event === 'chunk')
|
||||
.map((e) => {
|
||||
const data = e.data as { text?: string };
|
||||
return data.text || '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
// The response should reference the previous question about 2+2
|
||||
expect(textContent.toLowerCase()).toMatch(/2.*\+.*2|previous|earlier|asked/);
|
||||
});
|
||||
|
||||
it('should execute tool calls when requested', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a unique conversation for tool test
|
||||
const toolConvSlug = `tool-test-${Date.now()}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${toolConvSlug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Please execute this Python code: print("Hello from test")',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const text = await response.text();
|
||||
const events = parseSSEResponse(text);
|
||||
|
||||
// Should have tool call events if the agent has code execution tools
|
||||
const toolCallEvents = events.filter((e) => e.event === 'tool_call');
|
||||
const toolResultEvents = events.filter((e) => e.event === 'tool_result');
|
||||
|
||||
if (toolCallEvents.length > 0 && toolCallEvents[0]) {
|
||||
console.log(`✓ Tool called: ${JSON.stringify(toolCallEvents[0].data)}`);
|
||||
// Tool results may come in same stream or be processed async
|
||||
// The important thing is the tool was called
|
||||
console.log(` Tool results received: ${toolResultEvents.length}`);
|
||||
|
||||
// Verify tool call was saved to database
|
||||
const conversation = await prisma.conversation.findFirst({
|
||||
where: {
|
||||
agentId: testAgent.id,
|
||||
slug: toolConvSlug,
|
||||
},
|
||||
include: {
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
|
||||
const toolMessage = conversation?.messages.find((m) => m.role === 'TOOL');
|
||||
if (toolMessage) {
|
||||
expect(toolMessage.toolName).toBeDefined();
|
||||
expect(toolMessage.toolCallId).toBeDefined();
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await prisma.conversation.deleteMany({
|
||||
where: { agentId: testAgent.id, slug: toolConvSlug },
|
||||
});
|
||||
} else {
|
||||
console.log('ℹ️ No tool calls made (agent may not have tool access)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/[id]/conversation/[conversationId]', () => {
|
||||
it('should retrieve conversation history', async () => {
|
||||
if (!testAgent || !testConversationId) {
|
||||
console.log('Skipping: No test conversation available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data.slug).toBe(TEST_CONVERSATION_SLUG);
|
||||
expect(result.data.messages).toBeInstanceOf(Array);
|
||||
expect(result.data.messages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent conversation', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.uid}/conversation/non-existent-conv-12345`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/[id]/conversations', () => {
|
||||
it('should list all conversations for an agent', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeInstanceOf(Array);
|
||||
expect(result.pagination).toBeDefined();
|
||||
|
||||
// Our test conversation should be in the list
|
||||
const testConv = result.data.find((c: { slug: string }) => c.slug === TEST_CONVERSATION_SLUG);
|
||||
expect(testConv).toBeDefined();
|
||||
expect(testConv.messageCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/api/agents/${testAgent.id}/conversations?limit=1&offset=0`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.pagination.limit).toBe(1);
|
||||
expect(result.data.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database State Verification', () => {
|
||||
it('should have correct message token counts', async () => {
|
||||
if (!testConversationId) {
|
||||
console.log('Skipping: No test conversation available');
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { conversationId: testConversationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Assistant messages should have token counts
|
||||
const assistantMessages = messages.filter((m) => m.role === 'ASSISTANT');
|
||||
for (const msg of assistantMessages) {
|
||||
expect(msg.inputTokens).toBeGreaterThan(0);
|
||||
expect(msg.outputTokens).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly track tool calls in messages', async () => {
|
||||
if (!testConversationId) {
|
||||
console.log('Skipping: No test conversation available');
|
||||
return;
|
||||
}
|
||||
|
||||
const toolMessages = await prisma.message.findMany({
|
||||
where: {
|
||||
conversationId: testConversationId,
|
||||
role: 'TOOL',
|
||||
},
|
||||
});
|
||||
|
||||
for (const msg of toolMessages) {
|
||||
expect(msg.toolCallId).toBeDefined();
|
||||
expect(msg.toolName).toBeDefined();
|
||||
expect(msg.toolResult).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent with lookup by ID or UID', () => {
|
||||
it('should accept agent ID in URL', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const result = await response.json();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept agent UID in URL', async () => {
|
||||
if (!testAgent) {
|
||||
console.log('Skipping: No test agent available');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}/api/agents/${testAgent.uid}/conversations`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const result = await response.json();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue