218 lines
7.9 KiB
TypeScript
218 lines
7.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
InMemoryInteractionCoordinationPort,
|
|
type InteractionCoordinationPort,
|
|
type Handoff,
|
|
} from '@mosaicstack/coord';
|
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
|
import {
|
|
InteractionCoordinationService,
|
|
type InteractionCoordinationConfig,
|
|
type InteractionCoordinationGatewayError,
|
|
} from './interaction-coordination.service.js';
|
|
|
|
const context: RuntimeProviderRequestContext = {
|
|
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
|
channelId: 'cli',
|
|
correlationId: 'corr-1',
|
|
};
|
|
|
|
const config: InteractionCoordinationConfig = {
|
|
interactionAgentId: 'Nova',
|
|
orchestrationAgentId: 'Conductor',
|
|
};
|
|
|
|
function service(
|
|
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
|
options: {
|
|
config?: InteractionCoordinationConfig;
|
|
handoffIdFactory?: () => string;
|
|
} = {},
|
|
): InteractionCoordinationService {
|
|
return new InteractionCoordinationService(
|
|
port,
|
|
options.config ?? config,
|
|
options.handoffIdFactory ?? (() => 'handoff-1'),
|
|
);
|
|
}
|
|
|
|
describe('InteractionCoordinationService authority boundary', (): void => {
|
|
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
|
const adapter = new InMemoryInteractionCoordinationPort();
|
|
const coordination = service(adapter);
|
|
|
|
await expect(
|
|
coordination.handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
),
|
|
).resolves.toEqual({
|
|
handoffId: 'handoff-1',
|
|
targetAgentId: 'Conductor',
|
|
status: 'queued',
|
|
correlationId: 'corr-1',
|
|
});
|
|
|
|
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
|
|
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
|
|
|
const followUpContext = { ...context, correlationId: 'corr-2' };
|
|
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
|
targetAgentId: 'Conductor',
|
|
status: 'completed',
|
|
});
|
|
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
|
targetAgentId: 'Conductor',
|
|
status: 'completed',
|
|
summary: 'Merged by orchestrator',
|
|
});
|
|
});
|
|
|
|
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
|
const adapter = new InMemoryInteractionCoordinationPort();
|
|
const handoff = vi.spyOn(adapter, 'handoff');
|
|
const coordination = service(adapter, {
|
|
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
|
});
|
|
|
|
await expect(
|
|
coordination.handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
),
|
|
).rejects.toMatchObject({
|
|
code: 'unconfigured_requester',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
expect(handoff).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
|
const adapter = new InMemoryInteractionCoordinationPort();
|
|
const handoff = vi.spyOn(adapter, 'handoff');
|
|
const coordination = service(adapter, {
|
|
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
|
});
|
|
|
|
await expect(
|
|
coordination.handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
),
|
|
).rejects.toThrow('Interaction and orchestration identities must differ');
|
|
expect(handoff).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
|
const adapter = new InMemoryInteractionCoordinationPort();
|
|
const observe = vi.spyOn(adapter, 'observe');
|
|
const result = vi.spyOn(adapter, 'result');
|
|
const coordination = service(adapter);
|
|
await coordination.handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
);
|
|
|
|
const otherTenant = {
|
|
...context,
|
|
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
|
};
|
|
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
|
code: 'cross_tenant_forbidden',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
|
code: 'cross_tenant_forbidden',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
expect(observe).not.toHaveBeenCalled();
|
|
expect(result).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
|
let handoffSequence = 0;
|
|
let release: (() => void) | undefined;
|
|
const delivered = new Promise<void>((resolve: () => void): void => {
|
|
release = resolve;
|
|
});
|
|
const adapter: InteractionCoordinationPort = {
|
|
handoff: vi.fn(async (handoff: Handoff) => {
|
|
await delivered;
|
|
return {
|
|
handoffId: handoff.handoffId,
|
|
targetAgentId: handoff.targetAgentId,
|
|
status: 'queued' as const,
|
|
correlationId: handoff.scope.correlationId,
|
|
};
|
|
}),
|
|
observe: vi.fn(),
|
|
result: vi.fn(),
|
|
};
|
|
const coordination = service(adapter, {
|
|
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
|
});
|
|
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
|
|
|
const first = coordination.handoff(request, context);
|
|
const retry = coordination.handoff(request, context);
|
|
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
|
release?.();
|
|
await expect(Promise.all([first, retry])).resolves.toEqual([
|
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
|
expect.objectContaining({ handoffId: 'handoff-1' }),
|
|
]);
|
|
|
|
await expect(
|
|
coordination.handoff(request, {
|
|
...context,
|
|
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
|
}),
|
|
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
|
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
|
const adapter = new InMemoryInteractionCoordinationPort();
|
|
const handoff = vi.spyOn(adapter, 'handoff');
|
|
const coordination = service(adapter);
|
|
await coordination.handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
);
|
|
|
|
await expect(
|
|
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
|
).rejects.toMatchObject({
|
|
code: 'handoff_conflict',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
await expect(
|
|
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_request',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
await expect(
|
|
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
|
).rejects.toMatchObject({
|
|
code: 'invalid_request',
|
|
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
expect(handoff).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
|
const adapter: InteractionCoordinationPort = {
|
|
handoff: vi.fn(async (handoff: Handoff) => ({
|
|
handoffId: handoff.handoffId,
|
|
targetAgentId: 'Unexpected',
|
|
status: 'accepted' as const,
|
|
correlationId: handoff.scope.correlationId,
|
|
})),
|
|
observe: vi.fn(),
|
|
result: vi.fn(),
|
|
};
|
|
|
|
await expect(
|
|
service(adapter).handoff(
|
|
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
|
context,
|
|
),
|
|
).rejects.toMatchObject({ code: 'target_drift' });
|
|
});
|
|
});
|