158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type {
|
|
HarnessActorContext,
|
|
HarnessAdapter,
|
|
HarnessCatalogEntry,
|
|
HarnessSelection,
|
|
} from '@mosaicstack/types';
|
|
import { HarnessOperationError } from '../harness.registry.js';
|
|
|
|
const CONTEXT: HarnessActorContext = {
|
|
actorId: 'contract-actor',
|
|
tenantId: 'contract-tenant',
|
|
seatId: 'contract-seat',
|
|
correlationId: 'contract-correlation',
|
|
};
|
|
|
|
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
|
}
|
|
|
|
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
|
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
|
if (!entry) {
|
|
throw new Error('contract fixture requires at least one catalog model');
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
|
try {
|
|
await run();
|
|
return undefined;
|
|
} catch (caught) {
|
|
return caught;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
|
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
|
* the native Pi adapter so both share one behavioral contract.
|
|
*/
|
|
export function runHarnessAdapterContract(
|
|
label: string,
|
|
createAdapter: () => HarnessAdapter,
|
|
): void {
|
|
describe(`harness adapter contract: ${label}`, () => {
|
|
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
|
const adapter = createAdapter();
|
|
const catalog = await adapter.catalog(CONTEXT);
|
|
const selection = toSelection(pickAvailable(catalog.models));
|
|
|
|
const created = await (
|
|
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
|
).snapshot();
|
|
const resumed = await (
|
|
await adapter.resume({
|
|
context: CONTEXT,
|
|
conversationId: 'conv-resume',
|
|
nativeSessionId: 'native-supplied-1',
|
|
selection,
|
|
})
|
|
).snapshot();
|
|
|
|
expect(created.nativeSessionId).toBeTruthy();
|
|
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
|
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
|
expect(created.seatId).toBe(CONTEXT.seatId);
|
|
});
|
|
|
|
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
|
const adapter = createAdapter();
|
|
const catalog = await adapter.catalog(CONTEXT);
|
|
const selection = toSelection(pickAvailable(catalog.models));
|
|
const handle = await adapter.create({
|
|
context: CONTEXT,
|
|
conversationId: 'conv-lifecycle',
|
|
selection,
|
|
});
|
|
|
|
await handle.attach({ clientId: 'browser-1' });
|
|
await handle.detach('browser-1');
|
|
const afterDetach = await handle.snapshot();
|
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
|
expect(afterDetach.state).not.toBe('evicted');
|
|
expect(afterDetach.state).not.toBe('ended');
|
|
|
|
await handle.evictProcess('idle_timeout');
|
|
const afterEvict = await handle.snapshot();
|
|
expect(afterEvict.state).toBe('evicted');
|
|
// The native session survives eviction (resumable); the process does not.
|
|
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
|
expect(afterEvict.processId).toBeUndefined();
|
|
|
|
await handle.endSession('session_ended');
|
|
const afterEnd = await handle.snapshot();
|
|
expect(afterEnd.state).toBe('ended');
|
|
// End is not an alias of evict.
|
|
expect(afterEnd.state).not.toBe(afterEvict.state);
|
|
});
|
|
|
|
it('never substitutes the first catalog row for an unknown selection', async () => {
|
|
const adapter = createAdapter();
|
|
const catalog = await adapter.catalog(CONTEXT);
|
|
const firstRow = catalog.models[0];
|
|
if (!firstRow) {
|
|
throw new Error('contract fixture requires a catalog model');
|
|
}
|
|
const start = toSelection(pickAvailable(catalog.models));
|
|
const handle = await adapter.create({
|
|
context: CONTEXT,
|
|
conversationId: 'conv-nosub',
|
|
selection: start,
|
|
});
|
|
|
|
const bogus: HarnessSelection = {
|
|
harnessId: adapter.id,
|
|
providerId: 'contract-ghost-provider',
|
|
modelId: 'contract-ghost-model',
|
|
};
|
|
const error = await captureError(() => handle.setModel(bogus));
|
|
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
const dto = (error as HarnessOperationError).dto;
|
|
expect(dto.code).toBe('selection_invalid');
|
|
expect(dto.selection).toEqual(bogus);
|
|
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
|
expect((await handle.snapshot()).selection).toEqual(start);
|
|
});
|
|
|
|
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
|
const adapter = createAdapter();
|
|
const descriptor = await adapter.describe(CONTEXT);
|
|
const catalog = await adapter.catalog(CONTEXT);
|
|
const selection = toSelection(pickAvailable(catalog.models));
|
|
const handle = await adapter.create({
|
|
context: CONTEXT,
|
|
conversationId: 'conv-interaction',
|
|
selection,
|
|
});
|
|
|
|
const response = {
|
|
requestId: 'interaction-1',
|
|
type: 'confirm',
|
|
accepted: true,
|
|
} as const;
|
|
|
|
if (descriptor.capabilities.includes('extensionUi')) {
|
|
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
|
} else {
|
|
const error = await captureError(() => handle.respondInteraction(response));
|
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
|
}
|
|
});
|
|
});
|
|
}
|