moving
This commit is contained in:
@@ -22,6 +22,13 @@ function sseDone(content: string): Response {
|
||||
return new Response(body, { status: 200 });
|
||||
}
|
||||
|
||||
function sseError(error: string, content = ''): Response {
|
||||
const body =
|
||||
`event: error\ndata: ${JSON.stringify({ error })}\n\n` +
|
||||
`event: done\ndata: ${JSON.stringify({ content, toolsUsed: [] })}\n\n`;
|
||||
return new Response(body, { status: 200 });
|
||||
}
|
||||
|
||||
describe('cron ai_task executor (#17)', () => {
|
||||
let server: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
@@ -57,6 +64,7 @@ describe('cron ai_task executor (#17)', () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function chatCalls() {
|
||||
@@ -102,9 +110,50 @@ describe('cron ai_task executor (#17)', () => {
|
||||
expect(row?.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('records a failed full-agent turn and keeps a one-shot schedule enabled', async () => {
|
||||
fetchMock.mockResolvedValue(sseError('INCOMPLETE_COMPLETION', 'partial answer'));
|
||||
const sendSpy = vi.spyOn(server.channelManager!, 'sendTo').mockResolvedValue(true);
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Failed one shot',
|
||||
cronExpr: '0 9 * * *',
|
||||
jobType: 'agent_task',
|
||||
jobConfig: {
|
||||
prompt: 'Do the complete thing',
|
||||
mode: 'ai_task',
|
||||
once: true,
|
||||
deliverTo: { platform: 'telegram', chatId: 'chat-1' },
|
||||
},
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
await expect(server.scheduler.executeJob(schedule)).rejects.toThrow(/INCOMPLETE_COMPLETION/);
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
||||
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats stream EOF without done as incomplete and keeps the one-shot retryable', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(
|
||||
'event: token\ndata: {"content":"partial"}\n\n',
|
||||
{ status: 200 },
|
||||
));
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Disconnected one shot',
|
||||
cronExpr: '0 9 * * *',
|
||||
jobType: 'agent_task',
|
||||
jobConfig: { prompt: 'Finish despite disconnects', mode: 'ai_task', once: true },
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
await expect(server.scheduler.executeJob(schedule)).rejects.toThrow(/before the done event/i);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
||||
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('legacy agent_task without mode keeps the toolless /v1/chat/completions path', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
choices: [{ message: { content: 'legacy output' } }],
|
||||
choices: [{ finish_reason: 'stop', message: { content: 'legacy output' } }],
|
||||
}), { status: 200 }));
|
||||
|
||||
const schedule = server.cronStore.create({
|
||||
@@ -123,6 +172,137 @@ describe('cron ai_task executor (#17)', () => {
|
||||
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('records a legacy task as failed instead of delivering a truncated completion', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
choices: [{ finish_reason: 'length', message: { content: 'partial legacy output' } }],
|
||||
}), { status: 200 }));
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Truncated legacy task',
|
||||
cronExpr: '0 11 * * *',
|
||||
jobType: 'agent_task',
|
||||
jobConfig: { prompt: 'Old style but complete it' },
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
await expect(server.scheduler.executeJob(schedule)).rejects.toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not persist a truncated Loop report or record the run as successful', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
choices: [{ finish_reason: 'length', message: { content: 'partial loop report' } }],
|
||||
}), { status: 200 }));
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Integrity loop',
|
||||
cronExpr: '0 12 * * *',
|
||||
jobType: 'loop',
|
||||
jobConfig: { prompt: 'Inspect the workspace' },
|
||||
workspaceId: wsId,
|
||||
});
|
||||
|
||||
await expect(server.scheduler.executeJob(schedule)).rejects.toMatchObject({
|
||||
code: 'INCOMPLETE_COMPLETION',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
||||
const mind = server.agentState.getWorkspaceMindDb(wsId);
|
||||
const persisted = mind?.getDatabase().prepare(
|
||||
`SELECT COUNT(*) AS count FROM memory_frames WHERE content LIKE '[Loop:%'`
|
||||
).get() as { count: number } | undefined;
|
||||
expect(persisted?.count ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('automatic tick refuses a workspace Loop after the role is downgraded to viewer', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
choices: [{ finish_reason: 'stop', message: { content: 'NOTHING_TO_DO' } }],
|
||||
}), { status: 200 }));
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Role downgrade guard',
|
||||
cronExpr: '*/5 * * * *',
|
||||
jobType: 'loop',
|
||||
jobConfig: { prompt: 'Inspect the workspace' },
|
||||
workspaceId: wsId,
|
||||
});
|
||||
server.multiMind.personal.getDatabase().prepare(
|
||||
"UPDATE cron_schedules SET next_run_at = datetime('now', '-1 minute') WHERE id = ?",
|
||||
).run(schedule.id);
|
||||
const workspaceMind = server.agentState.getWorkspaceMindDb(wsId)!;
|
||||
const countFrames = () => (workspaceMind.getDatabase().prepare(
|
||||
'SELECT COUNT(*) AS count FROM memory_frames',
|
||||
).get() as { count: number }).count;
|
||||
const framesBefore = countFrames();
|
||||
server.workspaceManager.update(wsId, { teamId: 'team-1', teamRole: 'viewer' });
|
||||
const leaseSpy = vi.spyOn(server.cronStore, 'acquireRunLease');
|
||||
const historyBefore = server.cronStore.getExecutionHistory(schedule.id).length;
|
||||
|
||||
try {
|
||||
const executed = await server.scheduler.tick();
|
||||
|
||||
expect(executed).toBe(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(countFrames()).toBe(framesBefore);
|
||||
expect(leaseSpy).not.toHaveBeenCalled();
|
||||
expect(server.cronStore.getExecutionHistory(schedule.id)).toHaveLength(historyBefore);
|
||||
expect(server.cronStore.getById(schedule.id)?.last_run_at).toBeNull();
|
||||
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(0);
|
||||
|
||||
server.workspaceManager.update(wsId, { teamId: 'team-1', teamRole: 'member' });
|
||||
expect(await server.scheduler.tick()).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(leaseSpy).toHaveBeenCalledOnce();
|
||||
expect(server.cronStore.getById(schedule.id)?.last_run_at).not.toBeNull();
|
||||
} finally {
|
||||
server.cronStore.update(schedule.id, { enabled: false });
|
||||
server.workspaceManager.update(wsId, { teamId: undefined, teamRole: undefined });
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a global agent task atomically when any target is a viewer', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Viewer target', group: 'work' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const viewerWorkspaceId = JSON.parse(res.body).id as string;
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Global role guard',
|
||||
cronExpr: '*/5 * * * *',
|
||||
jobType: 'agent_task',
|
||||
jobConfig: { prompt: 'Inspect every workspace', mode: 'ai_task' },
|
||||
workspaceId: '*',
|
||||
});
|
||||
server.multiMind.personal.getDatabase().prepare(
|
||||
"UPDATE cron_schedules SET next_run_at = datetime('now', '-1 minute') WHERE id = ?",
|
||||
).run(schedule.id);
|
||||
server.workspaceManager.update(viewerWorkspaceId, {
|
||||
teamId: 'team-1',
|
||||
teamRole: 'viewer',
|
||||
});
|
||||
const leaseSpy = vi.spyOn(server.cronStore, 'acquireRunLease');
|
||||
|
||||
try {
|
||||
expect(await server.scheduler.tick()).toBe(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(leaseSpy).not.toHaveBeenCalled();
|
||||
expect(server.cronStore.getExecutionHistory(schedule.id)).toEqual([]);
|
||||
expect(server.cronStore.getById(schedule.id)?.last_run_at).toBeNull();
|
||||
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
||||
expect(server.scheduler.getFailCount(schedule.id)).toBe(0);
|
||||
expect(server.cronStore.getDue().some((due) => due.id === schedule.id)).toBe(true);
|
||||
} finally {
|
||||
server.cronStore.update(schedule.id, { enabled: false });
|
||||
server.workspaceManager.update(viewerWorkspaceId, {
|
||||
teamId: undefined,
|
||||
teamRole: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('daily cap (24) skips execution before any agent turn', async () => {
|
||||
const schedule = server.cronStore.create({
|
||||
name: 'Capped',
|
||||
|
||||
Reference in New Issue
Block a user