844 lines
25 KiB
TypeScript
844 lines
25 KiB
TypeScript
import Fastify from 'fastify';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { registerTeamRoutes } from '@main/http/teams';
|
|
import type { HttpServices } from '@main/http';
|
|
import type {
|
|
TeamCreateConfigRequest,
|
|
TeamCreateRequest,
|
|
TeamLaunchRequest,
|
|
TeamLaunchResponse,
|
|
TeamProvisioningProgress,
|
|
TeamRuntimeState,
|
|
TeamSummary,
|
|
TeamViewSnapshot,
|
|
} from '@shared/types/team';
|
|
|
|
describe('HTTP team runtime routes', () => {
|
|
function createServicesMock() {
|
|
const launchTeam =
|
|
vi.fn<
|
|
(
|
|
request: TeamLaunchRequest,
|
|
onProgress: (progress: TeamProvisioningProgress) => void
|
|
) => Promise<TeamLaunchResponse>
|
|
>();
|
|
const getRuntimeState = vi.fn<(teamName: string) => Promise<TeamRuntimeState>>();
|
|
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
|
|
const stopTeam = vi.fn<(teamName: string) => Promise<void>>(() => Promise.resolve());
|
|
const getAliveTeams = vi.fn<() => string[]>();
|
|
const createTeam =
|
|
vi.fn<
|
|
(
|
|
request: TeamCreateRequest,
|
|
onProgress: (progress: TeamProvisioningProgress) => void
|
|
) => Promise<TeamLaunchResponse>
|
|
>();
|
|
const listTeams = vi.fn<() => Promise<TeamSummary[]>>();
|
|
const getTeamData = vi.fn<(teamName: string) => Promise<TeamViewSnapshot>>();
|
|
const getSavedRequest = vi.fn<(teamName: string) => Promise<TeamCreateRequest | null>>();
|
|
const createTeamConfig = vi.fn<(request: TeamCreateConfigRequest) => Promise<void>>(() =>
|
|
Promise.resolve()
|
|
);
|
|
const teamProvisioningService = {
|
|
createTeam,
|
|
launchTeam,
|
|
getRuntimeState,
|
|
getProvisioningStatus,
|
|
stopTeam,
|
|
getAliveTeams,
|
|
} as Pick<
|
|
NonNullable<HttpServices['teamProvisioningService']>,
|
|
| 'createTeam'
|
|
| 'launchTeam'
|
|
| 'getRuntimeState'
|
|
| 'getProvisioningStatus'
|
|
| 'stopTeam'
|
|
| 'getAliveTeams'
|
|
> as HttpServices['teamProvisioningService'];
|
|
const teamDataService = {
|
|
listTeams,
|
|
getTeamData,
|
|
getSavedRequest,
|
|
createTeamConfig,
|
|
} as Pick<
|
|
NonNullable<HttpServices['teamDataService']>,
|
|
'listTeams' | 'getTeamData' | 'getSavedRequest' | 'createTeamConfig'
|
|
> as HttpServices['teamDataService'];
|
|
|
|
const services = {
|
|
projectScanner: {} as HttpServices['projectScanner'],
|
|
sessionParser: {} as HttpServices['sessionParser'],
|
|
subagentResolver: {} as HttpServices['subagentResolver'],
|
|
chunkBuilder: {} as HttpServices['chunkBuilder'],
|
|
dataCache: {} as HttpServices['dataCache'],
|
|
updaterService: {} as HttpServices['updaterService'],
|
|
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
|
teamDataService,
|
|
teamProvisioningService,
|
|
} satisfies HttpServices;
|
|
|
|
return {
|
|
services,
|
|
launchTeam,
|
|
getRuntimeState,
|
|
getProvisioningStatus,
|
|
stopTeam,
|
|
getAliveTeams,
|
|
createTeam,
|
|
listTeams,
|
|
getTeamData,
|
|
getSavedRequest,
|
|
createTeamConfig,
|
|
};
|
|
}
|
|
|
|
async function createApp() {
|
|
const app = Fastify();
|
|
const mocks = createServicesMock();
|
|
registerTeamRoutes(app, mocks.services);
|
|
await app.ready();
|
|
return { app, ...mocks };
|
|
}
|
|
|
|
it('lists, gets, and creates draft teams through team data service', async () => {
|
|
const { app, listTeams, getTeamData, createTeamConfig } = await createApp();
|
|
listTeams.mockResolvedValue([
|
|
{
|
|
teamName: 'demo-team',
|
|
displayName: 'Demo Team',
|
|
description: 'Demo',
|
|
memberCount: 1,
|
|
taskCount: 0,
|
|
lastActivity: null,
|
|
pendingCreate: true,
|
|
},
|
|
]);
|
|
getTeamData.mockResolvedValue({
|
|
teamName: 'demo-team',
|
|
config: null,
|
|
tasks: [],
|
|
messages: [],
|
|
processes: [],
|
|
kanban: null,
|
|
} as unknown as TeamViewSnapshot);
|
|
|
|
try {
|
|
const listResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams',
|
|
});
|
|
expect(listResponse.statusCode).toBe(200);
|
|
expect(listResponse.json()[0]).toMatchObject({
|
|
teamName: 'demo-team',
|
|
pendingCreate: true,
|
|
});
|
|
|
|
const getResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/demo-team',
|
|
});
|
|
expect(getResponse.statusCode).toBe(200);
|
|
expect(getTeamData).toHaveBeenCalledWith('demo-team');
|
|
|
|
const createResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams',
|
|
payload: {
|
|
teamName: 'new-team',
|
|
displayName: 'New Team',
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
},
|
|
});
|
|
expect(createResponse.statusCode).toBe(201);
|
|
expect(createResponse.json()).toEqual({ teamName: 'new-team' });
|
|
expect(createTeamConfig).toHaveBeenCalledWith({
|
|
teamName: 'new-team',
|
|
displayName: 'New Team',
|
|
members: [
|
|
{
|
|
name: 'builder',
|
|
role: 'Engineer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
},
|
|
],
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('launches a team with validated request payload', async () => {
|
|
const { app, launchTeam } = await createApp();
|
|
launchTeam.mockResolvedValue({ runId: 'run-1' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
prompt: 'Resume work',
|
|
skipPermissions: false,
|
|
clearContext: true,
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ runId: 'run-1' });
|
|
expect(launchTeam).toHaveBeenCalledWith(
|
|
{
|
|
teamName: 'demo-team',
|
|
cwd: '/Users/test/project',
|
|
prompt: 'Resume work',
|
|
providerId: 'anthropic',
|
|
skipPermissions: false,
|
|
clearContext: true,
|
|
},
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('validates top-level create effort against the default Anthropic provider over HTTP', async () => {
|
|
const { app, createTeamConfig } = await createApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams',
|
|
payload: {
|
|
teamName: 'default-anthropic-effort-team',
|
|
members: [{ name: 'builder' }],
|
|
cwd: '/Users/test/project',
|
|
effort: 'max',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
expect(createTeamConfig).toHaveBeenCalledWith({
|
|
teamName: 'default-anthropic-effort-team',
|
|
members: [{ name: 'builder' }],
|
|
cwd: '/Users/test/project',
|
|
effort: 'max',
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('validates teammate runtime fields against the inherited top-level provider over HTTP create', async () => {
|
|
const { app, createTeamConfig } = await createApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams',
|
|
payload: {
|
|
teamName: 'inherited-backend-team',
|
|
members: [{ name: 'builder', providerBackendId: 'codex-native', effort: 'xhigh' }],
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
expect(createTeamConfig).toHaveBeenCalledWith({
|
|
teamName: 'inherited-backend-team',
|
|
members: [{ name: 'builder', providerBackendId: 'codex-native', effort: 'xhigh' }],
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('drops a stale known backend when launching with a different provider over HTTP', async () => {
|
|
const { app, launchTeam } = await createApp();
|
|
launchTeam.mockResolvedValue({ runId: 'run-2' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'codex-native',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(launchTeam).toHaveBeenCalledWith(
|
|
{
|
|
teamName: 'demo-team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
},
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('still rejects unknown provider backends over HTTP launch', async () => {
|
|
const { app, launchTeam } = await createApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
providerBackendId: 'unknown-backend',
|
|
model: 'sonnet',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error).toContain('providerBackendId must be valid');
|
|
expect(launchTeam).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('routes draft team launch through createTeam with saved metadata', async () => {
|
|
const { app, createTeam, getSavedRequest, launchTeam } = await createApp();
|
|
getSavedRequest.mockResolvedValue({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
description: 'Saved draft',
|
|
color: '#3366ff',
|
|
cwd: '/Users/test/saved-project',
|
|
prompt: 'Saved prompt',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
});
|
|
createTeam.mockResolvedValue({ runId: 'run-draft' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/draft-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
effort: 'high',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ runId: 'run-draft' });
|
|
expect(launchTeam).not.toHaveBeenCalled();
|
|
expect(createTeam).toHaveBeenCalledWith(
|
|
{
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
description: 'Saved draft',
|
|
color: '#3366ff',
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
cwd: '/Users/test/project',
|
|
prompt: 'Saved prompt',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'high',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
},
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('drops stale saved draft backend when draft launch switches provider over HTTP', async () => {
|
|
const { app, createTeam, getSavedRequest } = await createApp();
|
|
getSavedRequest.mockResolvedValue({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/saved-project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'medium',
|
|
limitContext: false,
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
});
|
|
createTeam.mockResolvedValue({ runId: 'run-draft-anthropic' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/draft-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(createTeam).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ providerBackendId: expect.any(String) }),
|
|
expect.any(Function)
|
|
);
|
|
expect(createTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName: 'draft-team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
model: 'sonnet',
|
|
effort: 'low',
|
|
}),
|
|
expect.any(Function)
|
|
);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('does not reuse saved draft model defaults when draft launch switches provider over HTTP', async () => {
|
|
const { app, createTeam, getSavedRequest } = await createApp();
|
|
getSavedRequest.mockResolvedValue({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/saved-project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'unknown-stale-backend' as never,
|
|
model: 'gpt-5.2',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
limitContext: true,
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
});
|
|
createTeam.mockResolvedValue({ runId: 'run-draft-anthropic-default' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/draft-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const [request] = createTeam.mock.calls.at(-1)!;
|
|
expect(request).toMatchObject({
|
|
teamName: 'draft-team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'anthropic',
|
|
});
|
|
expect(request.providerBackendId).toBeUndefined();
|
|
expect(request.model).toBeUndefined();
|
|
expect(request.effort).toBeUndefined();
|
|
expect(request.fastMode).toBeUndefined();
|
|
expect(request.limitContext).toBeUndefined();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('clears saved draft model when same-provider draft launch requests default over HTTP', async () => {
|
|
const { app, createTeam, getSavedRequest } = await createApp();
|
|
getSavedRequest.mockResolvedValue({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/saved-project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.2',
|
|
effort: 'medium',
|
|
limitContext: false,
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
});
|
|
createTeam.mockResolvedValue({ runId: 'run-draft-codex-default' });
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/draft-team/launch',
|
|
payload: {
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: null,
|
|
effort: 'low',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
const [request] = createTeam.mock.calls.at(-1)!;
|
|
expect(request).toMatchObject({
|
|
teamName: 'draft-team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
effort: 'low',
|
|
});
|
|
expect(request.model).toBeUndefined();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('returns saved metadata for draft team get without requiring config.json', async () => {
|
|
const { app, getSavedRequest, getTeamData } = await createApp();
|
|
getSavedRequest.mockResolvedValue({
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
});
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/draft-team',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
teamName: 'draft-team',
|
|
pendingCreate: true,
|
|
savedRequest: {
|
|
teamName: 'draft-team',
|
|
displayName: 'Draft Team',
|
|
cwd: '/Users/test/project',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
|
},
|
|
});
|
|
expect(getTeamData).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('rejects launch requests with non-absolute cwd', async () => {
|
|
const { app, launchTeam } = await createApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/launch',
|
|
payload: {
|
|
cwd: 'relative/path',
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toEqual({ error: 'cwd must be an absolute path' });
|
|
expect(launchTeam).not.toHaveBeenCalled();
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('returns runtime state, provisioning status, and stop results', async () => {
|
|
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } =
|
|
await createApp();
|
|
getRuntimeState
|
|
.mockResolvedValueOnce({
|
|
teamName: 'demo-team',
|
|
isAlive: true,
|
|
runId: 'run-2',
|
|
progress: {
|
|
runId: 'run-2',
|
|
teamName: 'demo-team',
|
|
state: 'ready',
|
|
message: 'Ready',
|
|
startedAt: '2026-03-12T00:00:00.000Z',
|
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
teamName: 'demo-team',
|
|
isAlive: false,
|
|
runId: null,
|
|
progress: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
teamName: 'demo-team',
|
|
isAlive: true,
|
|
runId: 'run-2',
|
|
progress: {
|
|
runId: 'run-2',
|
|
teamName: 'demo-team',
|
|
state: 'ready',
|
|
message: 'Ready',
|
|
startedAt: '2026-03-12T00:00:00.000Z',
|
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
|
},
|
|
});
|
|
getProvisioningStatus.mockResolvedValue({
|
|
runId: 'run-2',
|
|
teamName: 'demo-team',
|
|
state: 'ready',
|
|
message: 'Ready',
|
|
startedAt: '2026-03-12T00:00:00.000Z',
|
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
|
});
|
|
getAliveTeams.mockReturnValue(['demo-team']);
|
|
|
|
try {
|
|
const runtimeResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/demo-team/runtime',
|
|
});
|
|
expect(runtimeResponse.statusCode).toBe(200);
|
|
expect(runtimeResponse.json().isAlive).toBe(true);
|
|
|
|
const provisioningResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/provisioning/run-2',
|
|
});
|
|
expect(provisioningResponse.statusCode).toBe(200);
|
|
expect(provisioningResponse.json().runId).toBe('run-2');
|
|
|
|
const stopResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/stop',
|
|
});
|
|
expect(stopResponse.statusCode).toBe(200);
|
|
expect(stopResponse.json()).toEqual({
|
|
teamName: 'demo-team',
|
|
isAlive: false,
|
|
runId: null,
|
|
progress: null,
|
|
});
|
|
expect(stopTeam).toHaveBeenCalledWith('demo-team');
|
|
|
|
const aliveResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/runtime/alive',
|
|
});
|
|
expect(aliveResponse.statusCode).toBe(200);
|
|
expect(aliveResponse.json()).toEqual([
|
|
{
|
|
teamName: 'demo-team',
|
|
isAlive: true,
|
|
runId: 'run-2',
|
|
progress: {
|
|
runId: 'run-2',
|
|
teamName: 'demo-team',
|
|
state: 'ready',
|
|
message: 'Ready',
|
|
startedAt: '2026-03-12T00:00:00.000Z',
|
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
|
},
|
|
},
|
|
]);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('returns 501 when team runtime routes are registered without a runtime service', async () => {
|
|
const app = Fastify();
|
|
registerTeamRoutes(app, {
|
|
projectScanner: {} as HttpServices['projectScanner'],
|
|
sessionParser: {} as HttpServices['sessionParser'],
|
|
subagentResolver: {} as HttpServices['subagentResolver'],
|
|
chunkBuilder: {} as HttpServices['chunkBuilder'],
|
|
dataCache: {} as HttpServices['dataCache'],
|
|
updaterService: {} as HttpServices['updaterService'],
|
|
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
|
} satisfies HttpServices);
|
|
await app.ready();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/runtime/alive',
|
|
});
|
|
|
|
expect(response.statusCode).toBe(501);
|
|
expect(response.json()).toEqual({
|
|
error: 'Team runtime control is not available in this mode',
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
|
|
it('serves member work sync diagnostics and explicit refresh routes', async () => {
|
|
const app = Fastify();
|
|
const mocks = createServicesMock();
|
|
const queueDiagnostics = {
|
|
queued: 0,
|
|
running: 0,
|
|
enqueued: 2,
|
|
coalesced: 1,
|
|
reconciled: 1,
|
|
dropped: 0,
|
|
failed: 0,
|
|
queuedItems: [],
|
|
runningItems: [],
|
|
};
|
|
const metrics = {
|
|
teamName: 'demo-team',
|
|
generatedAt: '2026-05-05T00:00:00.000Z',
|
|
memberCount: 1,
|
|
stateCounts: {
|
|
caught_up: 1,
|
|
needs_sync: 0,
|
|
still_working: 0,
|
|
blocked: 0,
|
|
inactive: 0,
|
|
unknown: 0,
|
|
},
|
|
actionableItemCount: 0,
|
|
wouldNudgeCount: 0,
|
|
fingerprintChangeCount: 0,
|
|
reportAcceptedCount: 0,
|
|
reportRejectedCount: 0,
|
|
recentEvents: [],
|
|
phase2Readiness: {
|
|
state: 'collecting_shadow_data',
|
|
reasons: ['insufficient_members'],
|
|
thresholds: {
|
|
minObservedMembers: 2,
|
|
minStatusEvents: 10,
|
|
minObservationHours: 1,
|
|
maxWouldNudgesPerMemberHour: 1,
|
|
maxFingerprintChangesPerMemberHour: 1,
|
|
maxReportRejectionRate: 0.1,
|
|
},
|
|
rates: {
|
|
observationHours: 0,
|
|
statusEventCount: 0,
|
|
wouldNudgesPerMemberHour: 0,
|
|
fingerprintChangesPerMemberHour: 0,
|
|
reportRejectionRate: 0,
|
|
},
|
|
diagnostics: [],
|
|
},
|
|
};
|
|
const refreshedStatus = {
|
|
teamName: 'demo-team',
|
|
memberName: 'bob',
|
|
state: 'caught_up',
|
|
agenda: {
|
|
teamName: 'demo-team',
|
|
memberName: 'bob',
|
|
generatedAt: '2026-05-05T00:00:00.000Z',
|
|
fingerprint: 'empty',
|
|
items: [],
|
|
diagnostics: [],
|
|
},
|
|
evaluatedAt: '2026-05-05T00:00:00.000Z',
|
|
diagnostics: [],
|
|
};
|
|
const memberWorkSyncFeature = {
|
|
getStatus: vi.fn(),
|
|
refreshStatus: vi.fn(async () => refreshedStatus),
|
|
getMetrics: vi.fn(async () => metrics),
|
|
report: vi.fn(async () => ({
|
|
accepted: true,
|
|
code: 'accepted',
|
|
message: 'ok',
|
|
status: refreshedStatus,
|
|
})),
|
|
noteTeamChange: vi.fn(),
|
|
enqueueStartupScan: vi.fn(),
|
|
replayPendingReports: vi.fn(),
|
|
dispatchDueNudges: vi.fn(),
|
|
buildRuntimeTurnSettledHookSettings: vi.fn(),
|
|
buildRuntimeTurnSettledEnvironment: vi.fn(),
|
|
drainRuntimeTurnSettledEvents: vi.fn(),
|
|
getQueueDiagnostics: vi.fn(() => queueDiagnostics),
|
|
dispose: vi.fn(),
|
|
};
|
|
registerTeamRoutes(app, {
|
|
...mocks.services,
|
|
memberWorkSyncFeature: memberWorkSyncFeature as any,
|
|
});
|
|
await app.ready();
|
|
|
|
try {
|
|
const diagnosticsResponse = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/teams/demo-team/member-work-sync/diagnostics',
|
|
});
|
|
expect(diagnosticsResponse.statusCode).toBe(200);
|
|
expect(diagnosticsResponse.json()).toMatchObject({
|
|
teamName: 'demo-team',
|
|
queue: queueDiagnostics,
|
|
metrics,
|
|
});
|
|
|
|
const refreshResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/member-work-sync/bob/refresh',
|
|
});
|
|
expect(refreshResponse.statusCode).toBe(200);
|
|
expect(refreshResponse.json()).toMatchObject(refreshedStatus);
|
|
expect(memberWorkSyncFeature.refreshStatus).toHaveBeenCalledWith({
|
|
teamName: 'demo-team',
|
|
memberName: 'bob',
|
|
});
|
|
|
|
const reportResponse = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/teams/demo-team/member-work-sync/report',
|
|
payload: {
|
|
memberName: 'bob',
|
|
state: 'still_working',
|
|
agendaFingerprint: 'agenda:v1:abc',
|
|
reportToken: 'wrs:v1.test.token',
|
|
taskIds: [' task-a ', '', 'task-a'],
|
|
},
|
|
});
|
|
expect(reportResponse.statusCode).toBe(200);
|
|
expect(memberWorkSyncFeature.report).toHaveBeenCalledWith({
|
|
teamName: 'demo-team',
|
|
memberName: 'bob',
|
|
state: 'still_working',
|
|
agendaFingerprint: 'agenda:v1:abc',
|
|
reportToken: 'wrs:v1.test.token',
|
|
taskIds: ['task-a'],
|
|
source: 'mcp',
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
});
|
|
});
|