264 lines
9.3 KiB
TypeScript
264 lines
9.3 KiB
TypeScript
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
|
import { firstValueFrom } from 'rxjs';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
RuntimeApprovalDeniedError,
|
|
RuntimeProviderService,
|
|
} from './runtime-provider-registry.service.js';
|
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
|
import { InteractionController } from './interaction.controller.js';
|
|
|
|
describe('InteractionController', (): void => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
|
const send = vi.fn();
|
|
const status = vi.fn().mockReturnValue({ send });
|
|
const response = { status };
|
|
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
|
|
|
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
|
|
|
expect(status).toHaveBeenCalledWith(403);
|
|
expect(send).toHaveBeenCalledWith({
|
|
statusCode: 403,
|
|
message: 'Runtime termination approval denied',
|
|
});
|
|
});
|
|
|
|
it('honors a differently named configured instance without a code change', async () => {
|
|
const prior = process.env['MOSAIC_AGENT_NAME'];
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
|
const controller = new InteractionController(runtime as never, {} as never);
|
|
|
|
await expect(
|
|
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
|
).resolves.toEqual([]);
|
|
await expect(
|
|
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
|
).rejects.toThrow('Interaction agent is not configured');
|
|
|
|
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
|
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
|
});
|
|
|
|
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const registry = createGatewayRuntimeProviderRegistry();
|
|
const runtime = new RuntimeProviderService(
|
|
registry,
|
|
{ record: vi.fn().mockResolvedValue(undefined) },
|
|
{ consume: vi.fn().mockResolvedValue(false) },
|
|
);
|
|
const controller = new InteractionController(runtime, {} as never);
|
|
|
|
await expect(
|
|
controller.transitionalCapabilities(
|
|
'Nova',
|
|
'runtime.hermes',
|
|
{ id: 'owner', tenantId: 'team' },
|
|
'corr-1',
|
|
),
|
|
).resolves.toEqual([
|
|
{ capability: 'kanban', status: 'unsupported' },
|
|
{ capability: 'skills', status: 'unsupported' },
|
|
{ capability: 'memory', status: 'unsupported' },
|
|
{ capability: 'tools', status: 'unsupported' },
|
|
{ capability: 'cron', status: 'unsupported' },
|
|
]);
|
|
});
|
|
|
|
it('rejects a request without the non-simple correlation header', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
|
|
|
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
|
'X-Correlation-Id is required',
|
|
);
|
|
});
|
|
|
|
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtime = {
|
|
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
|
};
|
|
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
await expect(
|
|
controller.enroll(
|
|
'Nova',
|
|
'conversation-1',
|
|
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
{ id: 'owner', tenantId: 'team' },
|
|
'corr-1',
|
|
),
|
|
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
|
expect(durable.enroll).toHaveBeenCalledWith(
|
|
{
|
|
agentName: 'Nova',
|
|
sessionId: 'conversation-1',
|
|
tenantId: 'team',
|
|
ownerId: 'owner',
|
|
providerId: 'fleet',
|
|
runtimeSessionId: 'runtime-1',
|
|
},
|
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
);
|
|
});
|
|
|
|
it('rejects an invalid attach mode before invoking a provider', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
|
|
|
await expect(
|
|
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
|
).rejects.toThrow('Interaction attach mode is invalid');
|
|
});
|
|
|
|
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtimeEvent = {
|
|
type: 'message.delta' as const,
|
|
sessionId: 'runtime-1',
|
|
cursor: 'cursor-1',
|
|
occurredAt: '2026-07-13T00:00:00.000Z',
|
|
content: 'resumed',
|
|
};
|
|
const runtime = {
|
|
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
|
streamSession: vi.fn(async function* () {
|
|
yield runtimeEvent;
|
|
}),
|
|
};
|
|
const durable = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
}),
|
|
};
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
|
await expect(
|
|
firstValueFrom(
|
|
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
|
),
|
|
).resolves.toEqual({ data: runtimeEvent });
|
|
|
|
expect(runtime.attach).toHaveBeenCalledWith(
|
|
'fleet',
|
|
'runtime-1',
|
|
'read',
|
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
);
|
|
expect(runtime.streamSession).toHaveBeenCalledWith(
|
|
'fleet',
|
|
'runtime-1',
|
|
undefined,
|
|
expect.objectContaining({ correlationId: 'corr-1' }),
|
|
);
|
|
});
|
|
|
|
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
|
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
});
|
|
const runtime = { streamSession: vi.fn() };
|
|
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
const subscription = controller
|
|
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
|
.subscribe();
|
|
subscription.unsubscribe();
|
|
resolveSnapshot({
|
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(runtime.streamSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
|
[
|
|
'session-agent mismatch',
|
|
{
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
}),
|
|
},
|
|
],
|
|
])('denies a CLI stop for %s', async (_reason, durable) => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
await expect(
|
|
controller.stop(
|
|
'Nova',
|
|
'durable-1',
|
|
{ approvalRef: 'approval-1' },
|
|
{ id: 'owner' },
|
|
'corr-1',
|
|
),
|
|
).rejects.toBeDefined();
|
|
expect(runtime.terminate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtime = {
|
|
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
|
};
|
|
const durable = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
}),
|
|
};
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
await expect(
|
|
controller.stop(
|
|
'Nova',
|
|
'durable-1',
|
|
{ approvalRef: 'approval-1' },
|
|
{ id: 'owner' },
|
|
'corr-1',
|
|
),
|
|
).rejects.toThrow('Runtime termination approval denied');
|
|
});
|
|
|
|
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
|
const durable = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
}),
|
|
};
|
|
const controller = new InteractionController(runtime as never, durable as never);
|
|
|
|
await controller.stop(
|
|
'Nova',
|
|
'durable-1',
|
|
{ approvalRef: 'approval-1' },
|
|
{ id: 'owner' },
|
|
'corr-1',
|
|
);
|
|
|
|
expect(runtime.terminate).toHaveBeenCalledWith(
|
|
'fleet',
|
|
'runtime-1',
|
|
'approval-1',
|
|
expect.objectContaining({
|
|
correlationId: 'corr-1',
|
|
actorScope: { userId: 'owner', tenantId: 'owner' },
|
|
}),
|
|
);
|
|
});
|
|
});
|