228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
|
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
|
import {
|
|
HarnessScopeViolationError,
|
|
HarnessService,
|
|
type TrustedGatewayScope,
|
|
} from './harness.service.js';
|
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
|
|
const SCOPE: TrustedGatewayScope = {
|
|
actorId: 'actor-trusted',
|
|
tenantId: 'tenant-trusted',
|
|
seatId: 'seat-trusted',
|
|
correlationId: 'correlation-trusted',
|
|
};
|
|
|
|
const READ_CONTEXT: HarnessActorContext = {
|
|
actorId: SCOPE.actorId,
|
|
tenantId: SCOPE.tenantId,
|
|
seatId: SCOPE.seatId,
|
|
correlationId: SCOPE.correlationId,
|
|
};
|
|
|
|
function setup(capabilities?: readonly HarnessCapability[]) {
|
|
const registry = new HarnessRegistry();
|
|
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
|
registry.register(adapter);
|
|
const service = new HarnessService(registry);
|
|
return { registry, adapter, service };
|
|
}
|
|
|
|
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
|
const entry = catalog.models.find((model) => model.availability === 'available');
|
|
if (!entry) {
|
|
throw new Error('fixture requires an available model');
|
|
}
|
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
|
}
|
|
|
|
describe('HarnessService', () => {
|
|
it('derives the actor context from trusted scope on create', async () => {
|
|
const { service, adapter } = setup();
|
|
const selection = await availableSelection(adapter);
|
|
|
|
const snapshot = await service.createSession(SCOPE, {
|
|
conversationId: 'conversation-1',
|
|
selection,
|
|
});
|
|
|
|
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
|
expect(snapshot.state).toBe('idle');
|
|
expect(snapshot.selection).toEqual(selection);
|
|
expect(snapshot.nativeSessionId).toBeTruthy();
|
|
});
|
|
|
|
it('rejects server-authority fields supplied by an external caller', async () => {
|
|
const { service, adapter } = setup();
|
|
const selection = await availableSelection(adapter);
|
|
|
|
const hostile = {
|
|
conversationId: 'conversation-1',
|
|
selection,
|
|
seatId: 'attacker-seat',
|
|
executablePath: '/usr/bin/evil',
|
|
home: '/home/attacker',
|
|
cwd: '/tmp/attacker',
|
|
nativeSessionPath: '/var/native/attacker.jsonl',
|
|
} as unknown as Parameters<HarnessService['createSession']>[1];
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.createSession(SCOPE, hostile);
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
|
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
|
});
|
|
|
|
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
|
const { service } = setup();
|
|
const selection: HarnessSelection = {
|
|
harnessId: 'ghost-harness',
|
|
providerId: 'p',
|
|
modelId: 'm',
|
|
};
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
const dto = (error as HarnessOperationError).dto;
|
|
expect(dto.code).toBe('adapter_unavailable');
|
|
expect(dto.selection).toEqual(selection);
|
|
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
|
});
|
|
|
|
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
|
const { service } = setup();
|
|
const selection: HarnessSelection = {
|
|
harnessId: 'fake',
|
|
providerId: 'ghost-provider',
|
|
modelId: 'ghost-model',
|
|
};
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
const dto = (error as HarnessOperationError).dto;
|
|
expect(dto.code).toBe('selection_invalid');
|
|
expect(dto.selection).toEqual(selection);
|
|
});
|
|
|
|
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
|
const { service, adapter } = setup();
|
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
|
expect(unavailable).toBeDefined();
|
|
const selection: HarnessSelection = {
|
|
harnessId: unavailable!.harnessId,
|
|
providerId: unavailable!.providerId,
|
|
modelId: unavailable!.modelId,
|
|
};
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
const dto = (error as HarnessOperationError).dto;
|
|
expect(dto.code).toBe('model_unavailable');
|
|
// No substitution: the DTO tuple is exactly what was requested.
|
|
expect(dto.selection).toEqual(selection);
|
|
});
|
|
|
|
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
|
const { service, adapter } = setup();
|
|
const selection = await availableSelection(adapter);
|
|
|
|
const created = await service.createSession(SCOPE, {
|
|
conversationId: 'conversation-create',
|
|
selection,
|
|
});
|
|
expect(created.state).toBe('idle');
|
|
expect(created.processId).toBeTruthy();
|
|
expect(created.attachedClientIds).toEqual([]);
|
|
|
|
const resumed = await service.resumeSession(SCOPE, {
|
|
conversationId: 'conversation-resume',
|
|
nativeSessionId: 'native-preexisting-123',
|
|
selection,
|
|
});
|
|
// Resume binds the supplied native session; create mints a fresh one.
|
|
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
|
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
|
|
|
await service.attach(SCOPE, {
|
|
conversationId: 'conversation-create',
|
|
clientId: 'browser-1',
|
|
});
|
|
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
|
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
|
|
|
const afterDetach = await service.detach(SCOPE, {
|
|
conversationId: 'conversation-create',
|
|
clientId: 'browser-1',
|
|
});
|
|
// Detach removes the browser attachment only; the process stays alive.
|
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
|
expect(afterDetach.state).toBe('idle');
|
|
expect(afterDetach.processId).toBeTruthy();
|
|
|
|
const afterEvict = await service.evict(SCOPE, {
|
|
conversationId: 'conversation-create',
|
|
reason: 'idle_timeout',
|
|
});
|
|
// Evict stops the process but retains the resumable native session.
|
|
expect(afterEvict.state).toBe('evicted');
|
|
expect(afterEvict.processId).toBeUndefined();
|
|
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
|
|
|
const afterEnd = await service.end(SCOPE, {
|
|
conversationId: 'conversation-create',
|
|
reason: 'session_ended',
|
|
});
|
|
// End destructively terminates the native session.
|
|
expect(afterEnd.state).toBe('ended');
|
|
});
|
|
|
|
it('fails typed when an unsupported capability is exercised', async () => {
|
|
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
|
(capability) => capability !== 'extensionUi',
|
|
);
|
|
const { service, adapter } = setup(withoutExtensionUi);
|
|
const selection = await availableSelection(adapter);
|
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.respondInteraction(SCOPE, {
|
|
conversationId: 'conversation-1',
|
|
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
|
});
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
|
});
|
|
});
|