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): Promise { 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'); } }); }); }