Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/durable-objects/project-data/row-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export function parseChatMessageRow(row: unknown): {
sessionId: r.session_id,
role: r.role,
content: r.content,
toolMetadata: r.tool_metadata ? JSON.parse(r.tool_metadata) : null,
toolMetadata: safeParseJson(r.tool_metadata),
createdAt: r.created_at,
sequence: r.sequence,
};
Expand Down
130 changes: 120 additions & 10 deletions apps/api/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,111 @@ import type { ChatSessionTaskEmbed } from '@simple-agent-manager/shared';
import { DEFAULT_CHAT_SESSION_MESSAGE_LIMIT, isTaskExecutionStep, isTaskMode } from '@simple-agent-manager/shared';
import { and, eq, inArray } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';
import type { Context } from 'hono';
import { Hono } from 'hono';

import * as schema from '../db/schema';
import type { Env } from '../env';
import { log } from '../lib/logger';
import { requireRouteParam } from '../lib/route-helpers';
import { getUserId, requireApproved,requireAuth } from '../middleware/auth';
import { getAuth, getUserId, requireApproved, requireAuth } from '../middleware/auth';
import { errors } from '../middleware/error';
import { requireOwnedProject } from '../middleware/project-auth';
import { CreateChatSessionSchema, LinkTaskToChatSchema,parseOptionalBody, SendChatMessageSchema } from '../schemas';
import { CreateChatSessionSchema, LinkTaskToChatSchema, parseOptionalBody, SendChatMessageSchema } from '../schemas';
import * as chatPersistence from '../services/chat-persistence';
import { persistError } from '../services/observability';
import * as projectDataService from '../services/project-data';
import { isTaskStatus } from '../services/task-status';

const chatRoutes = new Hono<{ Bindings: Env }>();

chatRoutes.use('/*', requireAuth(), requireApproved());

type ChatSessionLoadPhase = 'get_session' | 'get_messages';

function isDiagnosticRole(role: string): boolean {
return role === 'admin' || role === 'superadmin';
}

function serializeDiagnosticError(err: unknown): {
name: string;
message: string;
stack: string | null;
} {
if (err instanceof Error) {
return {
name: err.name,
message: err.message,
stack: err.stack ?? null,
};
}

return {
name: 'NonError',
message: String(err),
stack: null,
};
}

async function recordChatSessionLoadFailure(
c: Context<{ Bindings: Env }>,
input: {
err: unknown;
phase: ChatSessionLoadPhase;
projectId: string;
sessionId: string;
userId: string;
}
): Promise<Response> {
const requestId = crypto.randomUUID();
const diagnostic = serializeDiagnosticError(input.err);
const context = {
requestId,
route: 'GET /api/projects/:projectId/sessions/:sessionId',
phase: input.phase,
projectId: input.projectId,
sessionId: input.sessionId,
userId: input.userId,
errorName: diagnostic.name,
errorMessage: diagnostic.message,
};

log.error('chat.session_detail_load_failed', {
...context,
stack: diagnostic.stack,
});

if (c.env.OBSERVABILITY_DATABASE) {
await persistError(c.env.OBSERVABILITY_DATABASE, {
source: 'api',
level: 'error',
message: 'chat.session_detail_load_failed',
stack: diagnostic.stack,
context,
userId: input.userId,
ipAddress: c.req.header('CF-Connecting-IP') ?? null,
userAgent: c.req.header('User-Agent') ?? null,
});
}

const body: Record<string, unknown> = {
error: 'CHAT_SESSION_LOAD_FAILED',
message: 'Failed to load chat session',
requestId,
phase: input.phase,
};

if (isDiagnosticRole(getAuth(c).user.role)) {
body.details = {
errorName: diagnostic.name,
errorMessage: diagnostic.message,
stack: diagnostic.stack,
};
}

return c.json(body, 500);
}

function getSessionMessageLimit(env: Env, requestedLimit?: string): number {
const configuredLimit = parseInt(env.CHAT_SESSION_MESSAGE_LIMIT || '', 10);
const maxLimit = Number.isFinite(configuredLimit) && configuredLimit > 0
Expand Down Expand Up @@ -113,7 +200,19 @@ chatRoutes.get('/:sessionId', async (c) => {

await requireOwnedProject(db, projectId, userId);

const session = await projectDataService.getSession(c.env, projectId, sessionId);
let session: Awaited<ReturnType<typeof projectDataService.getSession>>;
try {
session = await projectDataService.getSession(c.env, projectId, sessionId);
} catch (err) {
return recordChatSessionLoadFailure(c, {
err,
phase: 'get_session',
projectId,
sessionId,
userId,
});
}

if (!session) {
throw errors.notFound('Chat session');
}
Expand All @@ -122,13 +221,24 @@ chatRoutes.get('/:sessionId', async (c) => {
const beforeParam = c.req.query('before');
const before = beforeParam ? parseInt(beforeParam, 10) : null;

const messagesResult = await projectDataService.getMessages(
c.env,
projectId,
sessionId,
limit,
before
);
let messagesResult: Awaited<ReturnType<typeof projectDataService.getMessages>>;
try {
messagesResult = await projectDataService.getMessages(
c.env,
projectId,
sessionId,
limit,
before
);
} catch (err) {
return recordChatSessionLoadFailure(c, {
err,
phase: 'get_messages',
projectId,
sessionId,
userId,
});
}

// Embed task summary if session is linked to a task (D1 lookup, best-effort)
let task: ChatSessionTaskEmbed | null = null;
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/services/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { PlatformErrorLevel,PlatformErrorSource } from '@simple-agent-manager/shared';
import type { SQL } from 'drizzle-orm';
import { and, count, desc, eq, gte, like, lte, or } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';

Expand Down Expand Up @@ -169,7 +170,7 @@ export async function queryErrors(
const { platformErrors } = observabilitySchema;

const limit = Math.min(params.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT);
const conditions: ReturnType<typeof eq>[] = [];
const conditions: SQL[] = [];

if (params.source && VALID_SOURCES.has(params.source)) {
conditions.push(eq(platformErrors.source, params.source));
Expand All @@ -188,7 +189,14 @@ export async function queryErrors(
}

if (params.search) {
conditions.push(like(platformErrors.message, `%${params.search}%`));
const searchPattern = `%${params.search}%`;
const searchCondition = or(
like(platformErrors.message, searchPattern),
like(platformErrors.context, searchPattern)
);
if (searchCondition) {
conditions.push(searchCondition);
}
}

// Cursor-based pagination: decode cursor as timestamp
Expand Down
24 changes: 12 additions & 12 deletions apps/api/tests/unit/durable-objects/row-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,18 +298,18 @@ describe('parseChatMessageRow', () => {
expect(result.sequence).toBeNull();
});

it('throws on invalid JSON in tool_metadata', () => {
expect(() =>
parseChatMessageRow({
id: 'm1',
session_id: 's1',
role: 'user',
content: 'hi',
tool_metadata: 'not-json',
created_at: 1000,
sequence: null,
})
).toThrow();
it('returns null for invalid JSON in tool_metadata', () => {
const result = parseChatMessageRow({
id: 'm1',
session_id: 's1',
role: 'user',
content: 'hi',
tool_metadata: 'not-json',
created_at: 1000,
sequence: null,
});

expect(result.toolMetadata).toBeNull();
});
});

Expand Down
117 changes: 117 additions & 0 deletions apps/api/tests/unit/routes/chat-session-agent-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
getMessages: vi.fn(),
listAcpSessions: vi.fn(),
persistError: vi.fn(async () => undefined),
userRole: 'user',
}));

vi.mock('drizzle-orm/d1', () => ({
Expand All @@ -27,6 +29,20 @@ vi.mock('../../../src/middleware/auth', () => ({
requireAuth: () => vi.fn((c: unknown, next: () => Promise<void>) => next()),
requireApproved: () => vi.fn((c: unknown, next: () => Promise<void>) => next()),
getUserId: () => 'user-1',
getAuth: () => ({
user: {
id: 'user-1',
email: 'user@example.com',
name: null,
avatarUrl: null,
role: mocks.userRole,
status: 'active',
},
session: {
id: 'session-1',
expiresAt: new Date('2030-01-01T00:00:00Z'),
},
}),
}));

vi.mock('../../../src/middleware/project-auth', () => ({
Expand All @@ -46,6 +62,10 @@ vi.mock('../../../src/services/project-data', () => ({
unlinkSessionIdea: vi.fn(),
}));

vi.mock('../../../src/services/observability', () => ({
persistError: mocks.persistError,
}));

vi.mock('../../../src/schemas', () => ({
CreateChatSessionSchema: {},
LinkTaskToChatSchema: {},
Expand All @@ -59,6 +79,7 @@ describe('chatRoutes agent session routing', () => {

beforeEach(() => {
vi.clearAllMocks();
mocks.userRole = 'user';

orderBySpy = vi.fn(() => ({
limit: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -235,6 +256,102 @@ describe('chatRoutes agent session routing', () => {
);
});

it('returns a structured diagnostic response when session lookup fails', async () => {
const loadError = new Error('Durable Object session lookup failed');
mocks.getSession.mockRejectedValue(loadError);

const response = await app.request(
'/api/projects/proj-1/sessions/chat-1',
{ method: 'GET', headers: { 'User-Agent': 'vitest' } },
{
DATABASE: {} as D1Database,
OBSERVABILITY_DATABASE: {} as D1Database,
} as Env,
);

expect(response.status).toBe(500);
const body = await response.json();
expect(body).toEqual({
error: 'CHAT_SESSION_LOAD_FAILED',
message: 'Failed to load chat session',
requestId: expect.any(String),
phase: 'get_session',
});
expect(body.details).toBeUndefined();
expect(mocks.persistError).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
source: 'api',
level: 'error',
message: 'chat.session_detail_load_failed',
stack: expect.stringContaining('Durable Object session lookup failed'),
userId: 'user-1',
userAgent: 'vitest',
context: expect.objectContaining({
requestId: body.requestId,
route: 'GET /api/projects/:projectId/sessions/:sessionId',
phase: 'get_session',
projectId: 'proj-1',
sessionId: 'chat-1',
userId: 'user-1',
errorName: 'Error',
errorMessage: 'Durable Object session lookup failed',
}),
}),
);
});

it('returns safe diagnostics for regular users when message lookup fails', async () => {
mocks.getMessages.mockRejectedValue(new Error('Malformed tool metadata'));

const response = await app.request(
'/api/projects/proj-1/sessions/chat-1',
{ method: 'GET' },
{
DATABASE: {} as D1Database,
OBSERVABILITY_DATABASE: {} as D1Database,
} as Env,
);

expect(response.status).toBe(500);
const body = await response.json();
expect(body).toEqual({
error: 'CHAT_SESSION_LOAD_FAILED',
message: 'Failed to load chat session',
requestId: expect.any(String),
phase: 'get_messages',
});
expect(body.details).toBeUndefined();
});

it('includes sanitized diagnostic details for admins when message lookup fails', async () => {
mocks.userRole = 'admin';
mocks.getMessages.mockRejectedValue(new Error('Malformed tool metadata'));

const response = await app.request(
'/api/projects/proj-1/sessions/chat-1',
{ method: 'GET' },
{
DATABASE: {} as D1Database,
OBSERVABILITY_DATABASE: {} as D1Database,
} as Env,
);

expect(response.status).toBe(500);
const body = await response.json();
expect(body).toMatchObject({
error: 'CHAT_SESSION_LOAD_FAILED',
message: 'Failed to load chat session',
requestId: expect.any(String),
phase: 'get_messages',
details: {
errorName: 'Error',
errorMessage: 'Malformed tool metadata',
stack: expect.stringContaining('Malformed tool metadata'),
},
});
});

it('returns null agentType when ACP session has no agentType', async () => {
mocks.listAcpSessions.mockResolvedValue({
sessions: [
Expand Down
Loading
Loading