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
24 changes: 8 additions & 16 deletions packages/api/src/activity/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type {Viewer} from '@proxy/db/viewer';

const listAuditEntries = mock();
const listTaskEntries = mock();
const listLastTaskEntries = mock();

const listMemberNames = mock(async (): Promise<unknown> => Ok([]));

Expand All @@ -14,7 +13,7 @@ mock.module('@proxy/db/organization', () => ({getMembership: mock(async () => Ok
const requireViewer = mock(async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments: []}));

mock.module('../utils/user-org', () => ({requireViewer, requireOwner: requireViewer}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, listTaskEntries, listLastTaskEntries}));
mock.module('@proxy/db/audit', () => ({listAuditEntries, listTaskEntries}));

const listAgentTasks = mock();
const listAgentTasksBetween = mock();
Expand All @@ -33,7 +32,6 @@ beforeEach(() => {
listAgentTasks.mockResolvedValue(Ok([]));
listAgentTasksBetween.mockResolvedValue(Ok([]));
listSubTasks.mockResolvedValue(Ok([]));
listLastTaskEntries.mockResolvedValue(Ok([]));
setSystemTime(new Date('2026-10-15T23:30:00Z'));
});

Expand All @@ -54,7 +52,7 @@ describe('handleActivityTimelineRoute', () => {
createdAt: new Date('2026-10-15T20:00:00Z'),
finishedAt: null,
outcome: null,
lastStartedAt: new Date('2026-10-15T20:00:00Z'),
lastActiveAt: new Date('2026-10-15T20:00:00Z'),
};
const entry = {
id: 'entry-1',
Expand All @@ -81,8 +79,6 @@ describe('handleActivityTimelineRoute', () => {
expect(listAgentTasksBetween).toHaveBeenCalledWith(viewer, new Date('2026-10-14T23:30:00Z'), new Date('2026-10-15T23:30:00Z'), 200);
expect(listSubTasks).toHaveBeenCalledWith(viewer, ['task-1'], 1000, {newest: true});
expect(listTaskEntries).toHaveBeenCalledWith(viewer, ['task-1'], 2000, {from: new Date('2026-10-14T23:30:00Z'), to: new Date('2026-10-15T23:30:00Z')});
// Read up to now, there is nothing after the stretch to read.
expect(listLastTaskEntries).not.toHaveBeenCalled();
const {createdAt, ...fields} = entry;
expect(result.unwrap()).toEqual({
now: '2026-10-15T23:30:00.000Z',
Expand All @@ -100,7 +96,7 @@ describe('handleActivityTimelineRoute', () => {
startedAt: '2026-10-15T20:00:00.000Z',
finishedAt: null,
outcome: null,
lastStartedAt: '2026-10-15T20:00:00.000Z',
lastActiveAt: '2026-10-15T20:00:00.000Z',
},
],
entries: [{...fields, at: createdAt.toISOString(), userName: null}],
Expand Down Expand Up @@ -129,7 +125,7 @@ describe('handleActivityTimelineRoute', () => {
createdAt: new Date(createdAt),
finishedAt: null,
outcome: null,
lastStartedAt: new Date(createdAt),
lastActiveAt: new Date(createdAt),
});
const notes = task('notes', null, '2026-10-01T10:00:00Z');
const pricing = task('pricing', null, '2026-10-01T10:20:00Z');
Expand All @@ -151,9 +147,7 @@ describe('handleActivityTimelineRoute', () => {
error: null,
createdAt: new Date('2026-10-01T10:30:00Z'),
};
const after = {...inside, id: 'after', createdAt: new Date('2026-10-03T08:00:00Z')};
listTaskEntries.mockResolvedValue(Ok([inside]));
listLastTaskEntries.mockResolvedValue(Ok([after]));
const {handleActivityTimelineRoute} = await import('./route');
const result = await handleActivityTimelineRoute(makeRequest({from: '2026-10-01T00:00:00Z', to: '2026-10-02T00:00:00Z'}) as never);

Expand All @@ -162,9 +156,7 @@ describe('handleActivityTimelineRoute', () => {
expect(listSubTasks).toHaveBeenCalledWith(viewer, ['pricing', 'notes'], 1000, {newest: true});
const stretch = {from: new Date('2026-10-01T00:00:00Z'), to: new Date('2026-10-02T00:00:00Z')};
expect(listTaskEntries).toHaveBeenCalledWith(viewer, ['pricing', 'sub', 'notes'], 2000, stretch);
// Each task's last request after the stretch, for when one given up on ended.
expect(listLastTaskEntries).toHaveBeenCalledWith(viewer, ['pricing', 'sub', 'notes'], stretch.to);
expect(result.unwrap().entries.map((one) => one.id)).toEqual(['inside', 'after']);
expect(result.unwrap().entries.map((one) => one.id)).toEqual(['inside']);
expect(result.unwrap().to).toBe('2026-10-02T00:00:00.000Z');
expect(result.unwrap().tasks.map((one) => one.id)).toEqual(['pricing', 'sub', 'notes']);
});
Expand Down Expand Up @@ -217,7 +209,7 @@ describe('handleListActivityRoute', () => {
createdAt: new Date('2026-10-15T09:00:00Z'),
finishedAt: new Date('2026-10-15T09:30:00Z'),
outcome: 'Archived 12 newsletters',
lastStartedAt: new Date('2026-10-15T09:00:00Z'),
lastActiveAt: new Date('2026-10-15T09:00:00Z'),
},
]),
);
Expand Down Expand Up @@ -275,7 +267,7 @@ describe('handleListActivityRoute', () => {
startedAt: '2026-10-15T09:00:00.000Z',
finishedAt: '2026-10-15T09:30:00.000Z',
outcome: 'Archived 12 newsletters',
lastStartedAt: '2026-10-15T09:00:00.000Z',
lastActiveAt: '2026-10-15T09:00:00.000Z',
},
],
});
Expand All @@ -294,7 +286,7 @@ describe('handleTaskRunRoute', () => {
createdAt: new Date(start),
finishedAt: finish === null ? null : new Date(finish),
outcome: null,
lastStartedAt: new Date(start),
lastActiveAt: new Date(start),
});
// Notes, with a sub-task and one under that; calendar, handed on from a task the person does not see.
const notes = task('notes', '2026-10-15T20:11:00Z', null);
Expand Down
22 changes: 9 additions & 13 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {Err, type Result} from 'ts-results-es';
import {z} from 'zod';

import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils';
import {type AuditEntryRow, listAuditEntries, listLastTaskEntries, listTaskEntries} from '@proxy/db/audit';
import {type AuditEntryRow, listAuditEntries, listTaskEntries} from '@proxy/db/audit';
import {listMemberNames} from '@proxy/db/organization';
import type {Look} from '@proxy/utils/harness';
import {listPersonasById} from '@proxy/db/harness';
Expand Down Expand Up @@ -58,28 +58,28 @@ export type ActivityTaskResponse = {
goal: string;
via: 'web' | 'mcp' | 'harness';
startedAt: string;
// Null while the agent hasn't called it finished, abandoned (@proxy/utils/tasks) or not.
// Null while the agent hasn't called it finished, left open (@proxy/utils/tasks) or not.
finishedAt: string | null;
// What was done or found, as the agent said when it finished; null where it said nothing.
outcome: string | null;
// When it, or the last of its sub-tasks at any depth, was started: one never finished is taken
// as abandoned a day after (@proxy/utils/tasks).
lastStartedAt: string;
// When it, or one of its sub-tasks at any depth, was last started or had a request made for it:
// one never finished is taken as left open 20 minutes after (@proxy/utils/tasks).
lastActiveAt: string;
};

// The tasks as the web app reads them, each with the persona it was run as, where it was.
function toActivityTasks(viewer: Viewer, rows: AgentTaskRow[]): Promise<Result<ActivityTaskResponse[], ApiError>> {
return Do(async ($) => {
const personaIds = new Set(rows.flatMap((row) => (row.personaId === null ? [] : [row.personaId])));
const personas = $(await listPersonasById(viewer.orgId, [...personaIds]));
return rows.map(({createdAt, finishedAt, lastStartedAt, personaId, ...task}) => {
return rows.map(({createdAt, finishedAt, lastActiveAt, personaId, ...task}) => {
const persona = personas.find((candidate) => candidate.id === personaId);
return {
...task,
persona: persona ? {id: persona.id, name: persona.name, look: persona.look, color: persona.color} : null,
startedAt: createdAt.toISOString(),
finishedAt: finishedAt?.toISOString() ?? null,
lastStartedAt: lastStartedAt.toISOString(),
lastActiveAt: lastActiveAt.toISOString(),
};
});
});
Expand Down Expand Up @@ -197,8 +197,7 @@ export type ActivityTimelineResponse = {
to: string;
// Every task under way at some point between `from` and `to`, with its sub-tasks, newest first.
tasks: ActivityTaskResponse[];
// The requests made for them between `from` and `to`, in the order they were made; and, where
// `to` is before now, each task's last made after it.
// The requests made for them between `from` and `to`, in the order they were made.
entries: AuditEntryResponse[];
};

Expand Down Expand Up @@ -227,15 +226,12 @@ export function handleActivityTimelineRoute(request: AuthenticatedRequest): Prom
const ids = tasks.map((task) => task.id);
// Only the stretch read shares out the most requests, so none goes to what came after it.
const rows = $(await listTaskEntries(viewer, ids, MAX_TIMELINE_ENTRIES, {from, to}));
// Read back from before now, a task given up on may have had requests made after the stretch:
// the last of them says when it ended (@/lib/timeline-window's endsOf in the web app).
const later = to < now ? $(await listLastTaskEntries(viewer, ids, to)) : [];
return {
now: now.toISOString(),
from: from.toISOString(),
to: to.toISOString(),
tasks: $(await toActivityTasks(viewer, tasks)),
entries: $(await toEntries(viewer, [...rows, ...later])),
entries: $(await toEntries(viewer, rows)),
};
});
}
5 changes: 3 additions & 2 deletions packages/api/src/agent-side/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export function handleAgentListTasksRoute(request: AgentRequest): Promise<Result
}

/**
* `GET /api/agent/tasks/:taskId`: the task while it is under way, as the agent pages open it from
* their address; unknown is 404, finished or abandoned 409, as a run for it would be.
* `GET /api/agent/tasks/:taskId`: the task while it is not finished, as the agent pages open it
* from their address; unknown is 404, finished, or under a task finished, 409, as a run for it
* would be. Only read: one left open is taken up again by the next run made for it.
*/
export function handleAgentGetTaskRoute(request: AgentRequest): Promise<Result<AgentTaskResponse, ApiError>> {
return getAgentOpenTask(request.agent, request.params.taskId ?? '');
Expand Down
101 changes: 64 additions & 37 deletions packages/api/src/agent-side/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ const createAgentTask = mock();
const getAgentTask = mock();
const finishAgentTask = mock();
const listOpenAgentTasks = mock();
const touchAgentTask = mock();
const isAgentTaskOpen = mock();

mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, listOpenAgentTasks}));
mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, isAgentTaskOpen, listOpenAgentTasks, touchAgentTask}));

const agent = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'claude', name: 'Claude', orgSlug: 'org-1', experiments: []};
const caller = {agent, via: 'mcp' as const};
Expand All @@ -21,7 +23,7 @@ const row = {
createdAt: new Date('2026-10-16T09:00:00Z'),
finishedAt: null,
outcome: null,
lastStartedAt: new Date('2026-10-16T09:00:00Z'),
lastActiveAt: new Date('2026-10-16T09:00:00Z'),
};
const finishedRow = {...row, finishedAt: new Date('2026-10-16T09:30:00Z'), outcome: 'Archived 12 newsletters'};
// The agent's task a sub-task is started for.
Expand All @@ -33,6 +35,8 @@ beforeEach(() => {
createAgentTask.mockResolvedValue(Ok(row));
getAgentTask.mockResolvedValue(Ok(row));
finishAgentTask.mockResolvedValue(Ok(finishedRow));
touchAgentTask.mockResolvedValue(Ok(true));
isAgentTaskOpen.mockResolvedValue(Ok(true));
});

describe('startAgentTask', () => {
Expand Down Expand Up @@ -62,8 +66,8 @@ describe('startAgentTask', () => {
});

describe('with a parent', () => {
// Three hours after the parent started.
beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z')));
// Ten minutes after the parent started.
beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z')));
afterEach(() => setSystemTime());

test('starts a sub-task of one of the agent’s tasks under way, answering with the parent', async () => {
Expand Down Expand Up @@ -95,29 +99,37 @@ describe('startAgentTask', () => {
expect(createAgentTask).not.toHaveBeenCalled();
});

test('a parent finished or abandoned is refused as a conflict, saying to start a new task', async () => {
test('a parent finished is refused as a conflict, saying to start a new task', async () => {
const {startAgentTask} = await import('./tasks');
getAgentTask.mockResolvedValue(Ok({...parentRow, finishedAt: new Date('2026-10-16T10:00:00Z')}));
const finished = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'});
getAgentTask.mockResolvedValue(Ok({...parentRow, lastStartedAt: new Date('2026-10-15T11:00:00Z')}));
const abandoned = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'});

expect(finished.unwrapErr()).toEqual({
kind: 'conflict',
statusCode: 409,
message: 'Task task-0 (Plan the week) is finished. Start a new task for what you are doing now, and give its id as task.',
});
expect(abandoned.unwrapErr()).toMatchObject({kind: 'conflict'});
expect(createAgentTask).not.toHaveBeenCalled();
});

test('a parent left open has the sub-task started under it, which takes it up again, not before', async () => {
getAgentTask.mockResolvedValue(Ok({...parentRow, lastActiveAt: new Date('2026-10-16T08:30:00Z')}));
createAgentTask.mockResolvedValue(Ok({...row, parentId: 'task-0'}));
const {startAgentTask} = await import('./tasks');
const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'});

expect(touchAgentTask).not.toHaveBeenCalled();
expect(createAgentTask).toHaveBeenCalledWith(expect.objectContaining({parentId: 'task-0'}));
expect(result.unwrap()).toEqual({...response, parent: 'task-0'});
});

test('a parent finished between the check and the start is refused as a conflict too', async () => {
getAgentTask.mockResolvedValue(Ok(parentRow));
createAgentTask.mockResolvedValue(Ok(null));
const {startAgentTask} = await import('./tasks');
const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'});

expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0 is no longer under way')});
expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0 (Plan the week), or a task it is part of, is finished')});
});
});
});
Expand Down Expand Up @@ -163,25 +175,30 @@ describe('finishAgentTask', () => {
expect(result.unwrapErr()).toMatchObject({kind: 'not_found', resource: 'task', id: 'task-9'});
});

test('a task abandoned, left unfinished, is refused as a conflict', async () => {
test('a task left open under a task finished without it is refused as a conflict', async () => {
finishAgentTask.mockResolvedValue(Ok(row));
const {finishAgentTask: finish} = await import('./tasks');
const result = await finish(caller, {task: 'task-1', outcome: 'Archived 12 newsletters'});

expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as abandoned')});
expect(result.unwrapErr()).toEqual({
kind: 'conflict',
statusCode: 409,
message: "Task task-1 (Triage inbox) was left open, and a task it is part of was finished without it, so it can't be finished any more.",
});
});
});

describe('requireOpenTask', () => {
// Three hours after the task started.
beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z')));
// Ten minutes after the task started.
beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z')));
afterEach(() => setSystemTime());

test('answers the agent’s task while it is under way', async () => {
test('answers the agent’s task while it is under way, the call counting as something done for it', async () => {
const {requireOpenTask} = await import('./tasks');
const result = await requireOpenTask(agent, 'task-1');

expect(getAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1');
expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', row);
expect(result.unwrap()).toBe(row);
});

Expand All @@ -198,40 +215,33 @@ describe('requireOpenTask', () => {
statusCode: 409,
message: 'Task task-1 (Triage inbox) is finished. Start a new task for what you are doing now, and give its id as task.',
});
expect(touchAgentTask).not.toHaveBeenCalled();
});

test('a task never finished is abandoned a day after it, or the last of its sub-tasks, started', async () => {
test('a task left open, 20 minutes or more with nothing done for it, is taken up again by the call', async () => {
const {requireOpenTask} = await import('./tasks');
setSystemTime(new Date('2026-10-17T08:59:59Z'));
const lastSecond = await requireOpenTask(agent, 'task-1');
setSystemTime(new Date('2026-10-17T09:00:00Z'));
const abandoned = await requireOpenTask(agent, 'task-1');
setSystemTime(new Date('2026-10-16T11:00:00Z'));
const result = await requireOpenTask(agent, 'task-1');

expect(lastSecond.unwrap()).toBe(row);
expect(abandoned.unwrapErr()).toEqual({
kind: 'conflict',
statusCode: 409,
message:
'Task task-1 (Triage inbox) was never finished, and neither it nor any of its sub-tasks was started in the last day, so it is taken as abandoned. Start a new task for what you are doing now, and give its id as task.',
});
expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', row);
expect(result.unwrap()).toBe(row);
});

test('a task started over a day ago is under way while a sub-task of it started within the day', async () => {
test('a task finished since it was read, or left open under a task finished, is refused as a conflict', async () => {
touchAgentTask.mockResolvedValue(Ok(false));
const {requireOpenTask} = await import('./tasks');
const withSubTask = {...row, lastStartedAt: new Date('2026-10-17T05:00:00Z')};
getAgentTask.mockResolvedValue(Ok(withSubTask));
setSystemTime(new Date('2026-10-17T12:00:00Z'));
const underWay = await requireOpenTask(agent, 'task-1');
setSystemTime(new Date('2026-10-18T05:00:00Z'));
const abandoned = await requireOpenTask(agent, 'task-1');

expect(underWay.unwrap()).toBe(withSubTask);
expect(abandoned.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as abandoned')});
const result = await requireOpenTask(agent, 'task-1');

expect(result.unwrapErr()).toEqual({
kind: 'conflict',
statusCode: 409,
message: 'Task task-1 (Triage inbox), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.',
});
});
});

describe('getAgentOpenTask', () => {
beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z')));
beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z')));
afterEach(() => setSystemTime());

test('answers the task while it is under way, and refuses it as a call for it would be', async () => {
Expand All @@ -243,4 +253,21 @@ describe('getAgentOpenTask', () => {
expect(open.unwrap()).toEqual(response);
expect(finished.unwrapErr()).toMatchObject({kind: 'conflict', statusCode: 409});
});

test('only reads the task: one left open stays so, and one under a task finished is refused', async () => {
const {getAgentOpenTask} = await import('./tasks');
setSystemTime(new Date('2026-10-16T11:00:00Z'));
const leftOpen = await getAgentOpenTask(agent, 'task-1');
isAgentTaskOpen.mockResolvedValue(Ok(false));
const under = await getAgentOpenTask(agent, 'task-1');

expect(leftOpen.unwrap()).toEqual(response);
expect(isAgentTaskOpen).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1');
expect(under.unwrapErr()).toEqual({
kind: 'conflict',
statusCode: 409,
message: 'Task task-1 (Triage inbox), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.',
});
expect(touchAgentTask).not.toHaveBeenCalled();
});
});
Loading
Loading