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
130 changes: 107 additions & 23 deletions packages/api/src/activity/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import {Ok} from 'ts-results-es';

const getUserOrgId = mock();
const listAuditEntries = mock();
const countAuditEntriesByDay = mock();
const countAuditEntriesByAction = mock();
const summarizeTaskRequests = mock();

mock.module('@proxy/db/organization', () => ({getUserOrgId}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, countAuditEntriesByDay, countAuditEntriesByAction}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, summarizeTaskRequests}));

const listAgentTasks = mock();
const listAgentTasksSince = mock();

mock.module('@proxy/db/task', () => ({listAgentTasks, listAgentTasksSince}));

function makeRequest(query: Record<string, string>) {
return {user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'}, query};
Expand All @@ -17,45 +21,69 @@ beforeEach(() => {
mock.clearAllMocks();
getUserOrgId.mockResolvedValue(Ok('org-1'));
listAuditEntries.mockResolvedValue(Ok([]));
countAuditEntriesByDay.mockResolvedValue(Ok([{day: '2026-10-16', agentId: null, count: 2, denied: 0}]));
countAuditEntriesByAction.mockResolvedValue(Ok([{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}]));
// Late on the 15th in UTC: already the 16th in Berlin.
listAgentTasks.mockResolvedValue(Ok([]));
listAgentTasksSince.mockResolvedValue(Ok([]));
summarizeTaskRequests.mockResolvedValue(Ok([]));
setSystemTime(new Date('2026-10-15T23:30:00Z'));
});

afterEach(() => {
setSystemTime();
});

describe('handleActivitySummaryRoute', () => {
test('counts a year of whole weeks and the past week, by days on the person’s clock', async () => {
const {handleActivitySummaryRoute} = await import('./route');
const result = await handleActivitySummaryRoute(makeRequest({timeZone: 'Europe/Berlin'}) as never);
describe('handleActivityLanesRoute', () => {
test('answers the tasks under way since the time asked for, with what was done for each', async () => {
const task = {id: 'task-1', agentId: 'agent-1', title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp', createdAt: new Date('2026-10-15T20:00:00Z'), finishedAt: null};
listAgentTasksSince.mockResolvedValue(Ok([task]));
summarizeTaskRequests.mockResolvedValue(
Ok([
{taskId: 'task-1', connectionId: 'notion-1', outcome: 'allowed', count: 5, first: new Date('2026-10-15T20:05:00Z')},
{taskId: 'task-1', connectionId: 'mail-1', outcome: 'allowed', count: 3, first: new Date('2026-10-15T20:01:00Z')},
{taskId: 'task-1', connectionId: 'mail-1', outcome: 'denied', count: 1, first: new Date('2026-10-15T20:40:00Z')},
]),
);
const {handleActivityLanesRoute} = await import('./route');
const result = await handleActivityLanesRoute(makeRequest({from: '2026-10-14T23:30:00Z'}) as never);

expect(listAgentTasksSince).toHaveBeenCalledWith('org-1', new Date('2026-10-14T23:30:00Z'), 200);
expect(summarizeTaskRequests).toHaveBeenCalledWith('org-1', ['task-1']);
expect(result.unwrap()).toEqual({
today: '2026-10-16',
days: [{day: '2026-10-16', agentId: null, count: 2, denied: 0}],
week: [{connectionId: 'c1', collectionId: 'events', action: 'create', outcome: 'allowed', count: 2}],
now: '2026-10-15T23:30:00.000Z',
from: '2026-10-14T23:30:00.000Z',
tasks: [
{
id: 'task-1',
agentId: 'agent-1',
title: 'Triage inbox',
goal: 'Archive the newsletters',
via: 'mcp',
startedAt: '2026-10-15T20:00:00.000Z',
finishedAt: null,
requests: 9,
denied: 1,
// Notion was used most, though the mailbox came first.
connectionIds: ['notion-1', 'mail-1'],
},
],
});
expect(countAuditEntriesByDay).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2025-10-11');
expect(countAuditEntriesByAction).toHaveBeenCalledWith('org-1', 'Europe/Berlin', '2026-10-10');
});

test('refuses what isn’t a time zone before it reaches the database', async () => {
const {handleActivitySummaryRoute} = await import('./route');
const result = await handleActivitySummaryRoute(makeRequest({timeZone: "UTC'; --"}) as never);
test('a window further back than a month is cut to one, and a time that is not one is refused', async () => {
const {handleActivityLanesRoute} = await import('./route');
await handleActivityLanesRoute(makeRequest({from: '2020-01-01T00:00:00Z'}) as never);
const bad = await handleActivityLanesRoute(makeRequest({from: 'yesterday'}) as never);

expect(result.unwrapErr().kind).toBe('parse_error');
expect(countAuditEntriesByDay).not.toHaveBeenCalled();
expect(listAgentTasksSince).toHaveBeenCalledWith('org-1', new Date('2026-09-14T23:30:00Z'), 200);
expect(bad.unwrapErr().kind).toBe('parse_error');
});
});

describe('handleListActivityRoute', () => {
test('narrows to one outcome and a span of time', async () => {
test('narrows to one outcome', async () => {
const {handleListActivityRoute} = await import('./route');
await handleListActivityRoute(makeRequest({outcome: 'denied', from: '2026-10-14T00:00:00+02:00', to: '2026-10-15T00:00:00+02:00'}) as never);
await handleListActivityRoute(makeRequest({outcome: 'denied'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {outcome: 'denied', from: new Date('2026-10-13T22:00:00Z'), to: new Date('2026-10-14T22:00:00Z')}, 500);
expect(listAuditEntries).toHaveBeenCalledWith('org-1', {outcome: 'denied'}, 500);
});

test('what Personal Agent Proxy did itself is the entries without an agent', async () => {
Expand All @@ -64,4 +92,60 @@ describe('handleListActivityRoute', () => {

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {agentId: null}, 500);
});

test('names the tasks the entries were made for, and the one asked for even without entries', async () => {
const entry = {
id: 'e1',
agentId: 'agent-1',
connectionId: 'c1',
collectionId: 'emails',
action: 'list',
recordTitle: null,
query: null,
outcome: 'allowed',
via: 'mcp',
taskId: 'task-1',
createdAt: new Date('2026-10-15T10:00:00Z'),
};
listAuditEntries.mockResolvedValue(Ok([entry, {...entry, id: 'e2', taskId: null, agentId: null}]));
listAgentTasks.mockResolvedValue(
Ok([{id: 'task-1', agentId: 'agent-1', title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp', createdAt: new Date('2026-10-15T09:00:00Z'), finishedAt: null}]),
);
const {handleListActivityRoute} = await import('./route');
const result = await handleListActivityRoute(makeRequest({taskId: 'task-2'}) as never);

expect(listAuditEntries).toHaveBeenCalledWith('org-1', {taskId: 'task-2'}, 500);
expect(listAgentTasks).toHaveBeenCalledWith('org-1', ['task-1', 'task-2']);
expect(result.unwrap()).toEqual({
entries: [
{
id: 'e1',
agentId: 'agent-1',
connectionId: 'c1',
collectionId: 'emails',
action: 'list',
recordTitle: null,
query: null,
outcome: 'allowed',
via: 'mcp',
taskId: 'task-1',
at: '2026-10-15T10:00:00.000Z',
},
{
id: 'e2',
agentId: null,
connectionId: 'c1',
collectionId: 'emails',
action: 'list',
recordTitle: null,
query: null,
outcome: 'allowed',
via: 'mcp',
taskId: null,
at: '2026-10-15T10:00:00.000Z',
},
],
tasks: [{id: 'task-1', agentId: 'agent-1', title: 'Triage inbox', goal: 'Archive the newsletters', via: 'mcp', startedAt: '2026-10-15T09:00:00.000Z', finishedAt: null}],
});
});
});
111 changes: 81 additions & 30 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type {Result} from 'ts-results-es';
import {z} from 'zod';

import {type ApiError, Do, parseSchema} from '@proxy/utils';
import {addDays, dayIn, isTimeZone} from '@proxy/utils/days';
import {type AuditActionCount, type AuditDayCount, countAuditEntriesByAction, countAuditEntriesByDay, listAuditEntries} from '@proxy/db/audit';
import {listAuditEntries, summarizeTaskRequests} from '@proxy/db/audit';
import {type AgentTaskRow, listAgentTasks, listAgentTasksSince} from '@proxy/db/task';

import type {AuthenticatedRequest} from '../server/middleware/require-auth';
import {requireUserOrgId} from '../utils/user-org';
Expand All @@ -24,61 +24,112 @@ export type AuditEntryResponse = {
outcome: 'allowed' | 'denied';
// The web app's agent pages, or the MCP server.
via: 'web' | 'mcp';
// The agent's task the request was made for, among the answer's `tasks`; none for entries from
// before tasks, and for what Personal Agent Proxy did itself.
taskId: string | null;
};

/** What an agent set out to do, as it said when it started: what the entries made for it were for. */
export type ActivityTaskResponse = {
id: string;
agentId: string;
title: string;
goal: string;
via: 'web' | 'mcp';
startedAt: string;
// Null while the agent hasn't called it finished, abandoned (@proxy/utils/tasks) or not.
finishedAt: string | null;
};

function toActivityTask({createdAt, finishedAt, ...task}: AgentTaskRow): ActivityTaskResponse {
return {...task, startedAt: createdAt.toISOString(), finishedAt: finishedAt?.toISOString() ?? null};
}

// `?agentId=proxy` is what Personal Agent Proxy did itself.
const PROXY = 'proxy';

const querySchema = z.object({
agentId: z.string().min(1).optional(),
connectionId: z.string().min(1).optional(),
taskId: z.string().min(1).optional(),
outcome: z.enum(['allowed', 'denied']).optional(),
// Only what was made from `from` until before `to`, as a day on the person's clock is.
from: z.iso.datetime({offset: true}).optional(),
to: z.iso.datetime({offset: true}).optional(),
});

/** The organization's agent requests, newest first, the latest 500. */
export function handleListActivityRoute(request: AuthenticatedRequest): Promise<Result<{entries: AuditEntryResponse[]}, ApiError>> {
/**
* The organization's agent requests, newest first, the latest 500, and the tasks they were made
* for; with `?taskId=`, that task too, even before any request was made for it.
*/
export function handleListActivityRoute(request: AuthenticatedRequest): Promise<Result<{entries: AuditEntryResponse[]; tasks: ActivityTaskResponse[]}, ApiError>> {
return Do(async ($) => {
const {agentId, connectionId, outcome, from, to} = $(parseSchema(querySchema, request.query));
const {agentId, connectionId, taskId, outcome} = $(parseSchema(querySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const filter = {
...(agentId === undefined ? {} : {agentId: agentId === PROXY ? null : agentId}),
...(connectionId === undefined ? {} : {connectionId}),
...(taskId === undefined ? {} : {taskId}),
...(outcome === undefined ? {} : {outcome}),
...(from === undefined ? {} : {from: new Date(from)}),
...(to === undefined ? {} : {to: new Date(to)}),
};
const rows = $(await listAuditEntries(orgId, filter, MAX_ENTRIES));
return {entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()}))};
const taskIds = new Set([...rows.flatMap((row) => (row.taskId === null ? [] : [row.taskId])), ...(taskId === undefined ? [] : [taskId])]);
const tasks = $(await listAgentTasks(orgId, [...taskIds]));
return {
entries: rows.map(({createdAt, ...entry}) => ({...entry, at: createdAt.toISOString()})),
tasks: tasks.map(toActivityTask),
};
});
}

// The home page's graph: 53 weeks, so a year of whole weeks ends in this one whatever day it is.
const GRAPH_DAYS = 53 * 7;
// Its summary line: today and the six days before it.
const WEEK_DAYS = 7;
// The home page's lanes: at most this many tasks, and a window at most this far back.
const MAX_TASKS = 200;
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;

const summaryQuerySchema = z.object({timeZone: z.string().refine(isTimeZone, 'Not a time zone')});
const lanesQuerySchema = z.object({from: z.iso.datetime({offset: true})});

/** A task as the home page's lanes draw it: when it ran, and what was done for it. */
export type LaneTaskResponse = ActivityTaskResponse & {
// How many requests were made for it, and how many of them were refused.
requests: number;
denied: number;
// The connections its requests used, the most used first, the earlier used first among equals.
connectionIds: string[];
};

export type ActivitySummaryResponse = {
// Today on the person's clock, `2026-10-16`, the day the graph ends on.
today: string;
// Each agent's requests each day of the graph's, on the person's clock, days without any left
// out; an `agentId` of null is what Personal Agent Proxy did itself.
days: AuditDayCount[];
// The past week's requests by connection, collection, action and outcome, the most first.
week: AuditActionCount[];
export type ActivityLanesResponse = {
// When the answer was made: the right edge of the lanes.
now: string;
from: string;
// Every task under way at some point since `from`, newest first.
tasks: LaneTaskResponse[];
};

/** The organization's activity by day for the home page's graph, and the past week's by action. */
export function handleActivitySummaryRoute(request: AuthenticatedRequest): Promise<Result<ActivitySummaryResponse, ApiError>> {
/** The organization's tasks under way at some point since `?from=`, with what was done for each. */
export function handleActivityLanesRoute(request: AuthenticatedRequest): Promise<Result<ActivityLanesResponse, ApiError>> {
return Do(async ($) => {
const {timeZone} = $(parseSchema(summaryQuerySchema, request.query));
const query = $(parseSchema(lanesQuerySchema, request.query));
const orgId = $(await requireUserOrgId(request));
const today = dayIn(new Date(), timeZone);
const [days, week] = await Promise.all([countAuditEntriesByDay(orgId, timeZone, addDays(today, 1 - GRAPH_DAYS)), countAuditEntriesByAction(orgId, timeZone, addDays(today, 1 - WEEK_DAYS))]);
return {today, days: $(days), week: $(week)};
const now = new Date();
const from = new Date(Math.max(new Date(query.from).getTime(), now.getTime() - MAX_WINDOW_MS));
const rows = $(await listAgentTasksSince(orgId, from, MAX_TASKS));
const requests = $(
await summarizeTaskRequests(
orgId,
rows.map((row) => row.id),
),
);
const tasks = rows.map((task) => {
const own = requests.filter((row) => row.taskId === task.id);
const used = new Map<string, {count: number; first: Date}>();
for (const row of own) {
const known = used.get(row.connectionId) ?? {count: 0, first: row.first};
used.set(row.connectionId, {count: known.count + row.count, first: row.first < known.first ? row.first : known.first});
}
return {
...toActivityTask(task),
requests: own.reduce((total, row) => total + row.count, 0),
denied: own.filter((row) => row.outcome === 'denied').reduce((total, row) => total + row.count, 0),
connectionIds: [...used.entries()].sort((a, b) => b[1].count - a[1].count || a[1].first.getTime() - b[1].first.getTime()).map(([connectionId]) => connectionId),
};
});
return {now: now.toISOString(), from: from.toISOString(), tasks};
});
}
9 changes: 9 additions & 0 deletions packages/api/src/agent-side/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('sendAgentFeedback', () => {
connectionId: 'mail-1',
tool: 'emails_list',
via: 'mcp',
taskId: null,
});
});

Expand All @@ -36,6 +37,14 @@ describe('sendAgentFeedback', () => {
expect(saveAgentFeedback).toHaveBeenCalledWith(expect.objectContaining({connectionId: null, tool: null, via: 'web'}));
});

test('keeps the task it names as it was given, one that was never started too', async () => {
const {sendAgentFeedback} = await import('./feedback');
const result = await sendAgentFeedback({agent, via: 'mcp'}, {message: 'start_task answered an id read refuses', task: 'task-9'});

expect(result.unwrap()).toEqual({sent: true});
expect(saveAgentFeedback).toHaveBeenCalledWith(expect.objectContaining({taskId: 'task-9'}));
});

test('an empty or overlong message is refused in words the agent can act on', async () => {
const {sendAgentFeedback} = await import('./feedback');
const empty = await sendAgentFeedback({agent, via: 'mcp'}, {message: ' '});
Expand Down
11 changes: 8 additions & 3 deletions packages/api/src/agent-side/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ import {saveAgentFeedback} from '@proxy/db/feedback';
import type {AgentCaller} from './tools';

// Feedback an agent sends the makers of Personal Agent Proxy, such as a tool that didn't work as it
// expected, over MCP or from the agent pages. It is kept for us to read; nothing in the app shows it.
// expected, over MCP or from the agent pages, with the task it was on where it names one. It is
// kept for us to read; nothing in the app shows it. The connection, the tool and the task are kept
// as the agent gave them, unchecked: feedback is how an agent says something went wrong, such as a
// task id that wasn't taken, so it is never refused for what it is about.

const MAX_MESSAGE = 5000;

const feedbackSchema = z.object({
message: z.string(),
connection: z.string().max(200).optional(),
tool: z.string().max(200).optional(),
task: z.string().max(200).optional(),
});

/** Keeps the agent's feedback: `{message}`, and the connection and tool it is about where it names them. */
/** Keeps the agent's feedback: `{message}`, and the connection, the tool and the task it is about where it names them. */
export function sendAgentFeedback(caller: AgentCaller, args: unknown): Promise<Result<{sent: true}, ApiError>> {
return Do(async ($) => {
const {message, connection, tool} = $(parseSchema(feedbackSchema, args));
const {message, connection, tool, task} = $(parseSchema(feedbackSchema, args));
const text = message.trim();
if (text === '') {
return $(Err(ApiErr.validationError('The feedback is empty: say what happened and what you expected.')));
Expand All @@ -37,6 +41,7 @@ export function sendAgentFeedback(caller: AgentCaller, args: unknown): Promise<R
connectionId: connection || null,
tool: tool || null,
via: caller.via,
taskId: task || null,
}),
);
return {sent: true};
Expand Down
Loading
Loading