Files
openchamber/packages/web/server/lib/openchamber-control/service.test.js
T

264 lines
11 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import { createOpenChamberControlService } from './service.js';
const createService = (overrides = {}) => {
const client = {
session: {
list: vi.fn(async () => ({ data: [] })),
status: vi.fn(async () => ({ data: {} })),
messages: vi.fn(async () => ({ data: [] })),
},
};
const sessionService = {
create: vi.fn(async () => ({ sessionId: 'ses_1', directory: '/repo', promptDispatched: false })),
send: vi.fn(),
fork: vi.fn(),
};
const scheduledTaskService = {
status: vi.fn(async () => ({ enabledScheduledTasksCount: 0 })),
resolveProjectID: vi.fn(async () => 'project-1'),
list: vi.fn(async () => []),
upsert: vi.fn(),
run: vi.fn(),
remove: vi.fn(),
setEnabled: vi.fn(),
};
const service = createOpenChamberControlService({
readSettingsFromDiskMigrated: vi.fn(async () => ({
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
defaultModel: 'provider/model',
favoriteModels: [],
recentModels: [],
})),
sanitizeProjects: (projects) => projects,
buildOpenCodeUrl: () => 'http://127.0.0.1:4096/',
getOpenCodeAuthHeaders: () => ({ authorization: 'Basic test' }),
waitForOpenCodeReady: vi.fn(),
createClient: vi.fn(() => client),
sessionService,
scheduledTaskService,
...overrides,
});
return { service, client, sessionService, scheduledTaskService };
};
describe('OpenChamber control service', () => {
it('serves project and model projections without an HTTP or CLI round trip', async () => {
const { service } = createService();
await expect(service.execute('projects.list')).resolves.toEqual({
projects: [{ id: 'project-1', path: '/repo', label: 'Repo' }],
});
await expect(service.execute('models.list')).resolves.toEqual(expect.objectContaining({
defaultModel: 'provider/model',
favoriteModels: [],
}));
});
it('maps schedule creation into the shared scheduled-task service', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.upsert.mockResolvedValue({ task: { id: 'task-1' }, created: true });
await expect(service.execute('schedule.create', {
directory: '/repo',
name: 'Daily',
prompt: 'Run checks',
model: 'provider/model',
daily: ' 09:00 ',
goal: true,
goalTokenBudget: 5000,
})).resolves.toEqual({ task: { id: 'task-1' }, created: true });
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: undefined, directory: '/repo' });
expect(scheduledTaskService.upsert).toHaveBeenCalledWith('project-1', expect.objectContaining({
name: 'Daily',
schedule: { kind: 'daily', times: ['09:00'] },
execution: expect.objectContaining({ providerID: 'provider', modelID: 'model', goalEnabled: true, goalTokenBudget: 5000 }),
}));
});
it('does not combine an explicit schedule project with the tool context directory', async () => {
const { service, scheduledTaskService } = createService();
await service.execute('schedule.list', { projectId: ' project-1 ' }, '/current-session');
expect(scheduledTaskService.resolveProjectID).toHaveBeenCalledWith({ projectId: 'project-1', directory: undefined });
});
it('includes scheduler status alongside listed tasks', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.list.mockResolvedValue([{ id: 'task-1' }]);
await expect(service.execute('schedule.list', {}, '/repo')).resolves.toEqual({
scheduler: { enabledScheduledTasksCount: 0 },
tasks: [{ id: 'task-1' }],
});
});
it('toggles a scheduled task through the required disabled boolean', async () => {
const { service, scheduledTaskService } = createService();
scheduledTaskService.setEnabled.mockResolvedValue({ id: 'task-1', enabled: false });
await expect(service.execute('schedule.toggle', { taskId: 'task-1' }, '/repo')).rejects.toThrow('disabled is required for schedule.toggle');
await expect(service.execute('schedule.toggle', { taskId: 'task-1', disabled: true }, '/repo')).resolves.toEqual({
task: { id: 'task-1', enabled: false },
enabled: false,
});
expect(scheduledTaskService.setEnabled).toHaveBeenCalledWith('project-1', 'task-1', false);
});
it('returns an actionable taskId error before resolving schedule scope', async () => {
const { service, scheduledTaskService } = createService();
await expect(service.execute('schedule.run', {}, '/repo')).rejects.toThrow('taskId is required');
expect(scheduledTaskService.resolveProjectID).not.toHaveBeenCalled();
expect(scheduledTaskService.run).not.toHaveBeenCalled();
});
it('validates wait modifiers before creating a session', async () => {
const { service, sessionService } = createService();
await expect(service.execute('session.create', { directory: '/repo', timeout: 30 })).rejects.toThrow('timeout requires wait');
expect(sessionService.create).not.toHaveBeenCalled();
});
it('uses the tool context directory for session actions', async () => {
const { service, sessionService } = createService();
await service.execute('session.create', { title: 'From tool' }, '/repo');
expect(sessionService.create).toHaveBeenCalledWith({ directory: '/repo', title: 'From tool' });
});
it.each([
['session.send', 'send'],
['session.fork', 'fork'],
])('delegates %s directly to the session service', async (action, method) => {
const { service, sessionService } = createService();
sessionService[method].mockResolvedValue({ sessionId: 'ses_1', directory: '/repo' });
await service.execute(action, { sessionId: 'ses_1', directory: '/repo', prompt: 'Continue' });
expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' });
});
it('resolves the target session directory from the global session list when send omits it', async () => {
const { service, sessionService, client } = createService({
createClient: () => ({
...client,
experimental: {
session: {
list: vi.fn(async () => ({
data: [
{ id: 'ses_other', directory: '/repo/worktrees/other' },
{ id: 'ses_target', directory: '/repo/worktrees/target' },
],
})),
},
},
}),
});
sessionService.send.mockResolvedValue({ sessionId: 'ses_target', directory: '/repo/worktrees/target', promptDispatched: true });
await service.execute('session.send', { sessionId: 'ses_target', prompt: 'Continue' }, '/repo');
expect(sessionService.send).toHaveBeenCalledWith('ses_target', { directory: '/repo/worktrees/target', prompt: 'Continue' });
});
it('falls back to the context directory when the session is not in the global list', async () => {
const { service, sessionService, client } = createService({
createClient: () => ({
...client,
experimental: { session: { list: vi.fn(async () => ({ data: [] })) } },
}),
});
sessionService.send.mockResolvedValue({ sessionId: 'ses_unknown', directory: '/repo', promptDispatched: true });
await service.execute('session.send', { sessionId: 'ses_unknown', prompt: 'Continue' }, '/repo');
expect(sessionService.send).toHaveBeenCalledWith('ses_unknown', { directory: '/repo', prompt: 'Continue' });
});
it('waits past initial idle until a completed assistant result appears', async () => {
let timestamp = 1000;
const { service, client, sessionService } = createService({
now: () => timestamp,
sleep: async (duration) => { timestamp += duration; },
});
sessionService.create.mockResolvedValue({
sessionId: 'ses_1',
directory: '/repo',
promptDispatched: true,
baselineAssistantMessageId: 'msg_old',
});
client.session.status.mockResolvedValue({ data: { ses_1: { type: 'idle' } } });
client.session.messages
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_old', role: 'assistant', time: { completed: 900 } }, parts: [{ type: 'text', text: 'old' }] }] })
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] })
.mockResolvedValueOnce({ data: [{ info: { id: 'msg_new', role: 'assistant', time: { completed: 1500 } }, parts: [{ type: 'text', text: 'done' }] }] });
await expect(service.execute('session.create', {
directory: '/repo',
prompt: 'work',
wait: true,
lastAssistant: true,
timeout: 2,
})).resolves.toEqual(expect.objectContaining({
sessionStatus: { type: 'idle' },
lastAssistantMessage: expect.objectContaining({ id: 'msg_new', text: 'done' }),
}));
expect(client.session.status).toHaveBeenCalledTimes(2);
});
it('filters archived sessions and adds directory-scoped statuses', async () => {
const { service, client } = createService();
client.session.list.mockResolvedValue({ data: [
{ id: 'ses_active', directory: '/repo', time: {} },
{ id: 'ses_archived', directory: '/repo', time: { archived: 100 } },
{ id: 'ses_other', directory: '/other', time: {} },
] });
client.session.status
.mockResolvedValueOnce({ data: { ses_active: { type: 'busy' } } })
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.execute('session.list', { limit: 10, withStatus: true })).resolves.toEqual({
sessions: [
{ id: 'ses_active', directory: '/repo', time: {}, status: { type: 'busy' } },
{ id: 'ses_other', directory: '/other', time: {}, status: { type: 'unknown' } },
],
limit: 10,
directory: null,
archived: 'excluded',
});
});
it('names limit in positive-integer validation errors', async () => {
const { service, client } = createService();
await expect(service.execute('session.list', { limit: 0 })).rejects.toThrow('limit must be a positive integer');
expect(client.session.list).not.toHaveBeenCalled();
});
it('projects only ordered text parts from session messages', async () => {
const { service, client } = createService();
client.session.messages.mockResolvedValue({ data: [
{
info: { id: 'msg_assistant', role: 'assistant', providerID: 'openai', modelID: 'gpt-5.4-mini', time: { created: 20, completed: 30 } },
parts: [{ type: 'reasoning', text: 'hidden' }, { type: 'text', text: 'First ' }, { type: 'tool' }, { type: 'text', text: 'answer' }],
},
{ info: { id: 'msg_user', role: 'user', time: { created: 10 } }, parts: [{ type: 'text', text: 'Question' }] },
{ info: { id: 'msg_tool', role: 'assistant', time: { created: 15 } }, parts: [{ type: 'tool' }] },
] });
await expect(service.execute('session.messages', {
sessionId: 'ses_1',
directory: '/repo',
role: 'all',
all: true,
})).resolves.toEqual({
sessionId: 'ses_1',
directory: '/repo',
role: 'all',
sessionStatus: { type: 'idle' },
messages: [
{ id: 'msg_user', role: 'user', createdAt: 10, completedAt: null, model: null, text: 'Question' },
{ id: 'msg_assistant', role: 'assistant', createdAt: 20, completedAt: 30, model: 'openai/gpt-5.4-mini', text: 'First answer' },
],
});
});
it('rejects actions outside the fixed contract', async () => {
const { service } = createService();
await expect(service.execute('session.delete')).rejects.toThrow('Unsupported OpenChamber action');
});
});