This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||
import { HarnessOperationError } from '../harness.registry.js';
|
||||
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||
|
||||
const CONTEXT: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'correlation-1',
|
||||
};
|
||||
|
||||
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||
|
||||
describe('FakeHarnessAdapter no-substitution', () => {
|
||||
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const firstRow = catalog.models[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('fixture requires a catalog model');
|
||||
}
|
||||
const available = catalog.models.find(
|
||||
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||
);
|
||||
expect(available).toBeDefined();
|
||||
const selected: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-1',
|
||||
selection: selected,
|
||||
});
|
||||
|
||||
const bogus: HarnessSelection = {
|
||||
harnessId: 'fake',
|
||||
providerId: 'ghost-provider',
|
||||
modelId: 'ghost-model',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(bogus);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
// The DTO echoes the exact requested tuple, unchanged.
|
||||
expect(dto.selection).toEqual(bogus);
|
||||
// No substitution to the first catalog row.
|
||||
expect(dto.selection).not.toEqual({
|
||||
harnessId: firstRow.harnessId,
|
||||
providerId: firstRow.providerId,
|
||||
modelId: firstRow.modelId,
|
||||
});
|
||||
// The active selection is untouched by the rejected request.
|
||||
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||
});
|
||||
|
||||
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||
expect(unavailable).toBeDefined();
|
||||
expect(available).toBeDefined();
|
||||
|
||||
const startingSelection: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-2',
|
||||
selection: startingSelection,
|
||||
});
|
||||
|
||||
const requested: HarnessSelection = {
|
||||
harnessId: unavailable!.harnessId,
|
||||
providerId: unavailable!.providerId,
|
||||
modelId: unavailable!.modelId,
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(requested);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('model_unavailable');
|
||||
expect(dto.selection).toEqual(requested);
|
||||
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
AttachClient,
|
||||
CreateHarnessSession,
|
||||
HarnessAdapter,
|
||||
HarnessActorContext,
|
||||
HarnessCapability,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessCloseReason,
|
||||
HarnessDescriptor,
|
||||
HarnessEvent,
|
||||
HarnessInteractionResponse,
|
||||
HarnessPrompt,
|
||||
HarnessPromptReceipt,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
HarnessSessionState,
|
||||
ResumeHarnessSession,
|
||||
} from '@mosaicstack/types';
|
||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||
import { operationError } from '../harness.registry.js';
|
||||
|
||||
export interface FakeHarnessAdapterOptions {
|
||||
readonly id: string;
|
||||
readonly capabilities?: readonly HarnessCapability[];
|
||||
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||
}
|
||||
|
||||
const FAKE_PROVIDER = 'fake-openai';
|
||||
|
||||
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||
return [
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-mini',
|
||||
displayName: 'Fake Mini',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-pro',
|
||||
displayName: 'Fake Pro',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text', 'image'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-legacy',
|
||||
displayName: 'Fake Legacy',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'unavailable',
|
||||
availability: 'unavailable',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||
return (
|
||||
entry.harnessId === selection.harnessId &&
|
||||
entry.providerId === selection.providerId &&
|
||||
entry.modelId === selection.modelId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory harness session handle used by the fake adapter and by the shared
|
||||
* conformance suite. It enforces the two invariants the real adapters must also
|
||||
* honor: model selection is validated against the catalog and is NEVER
|
||||
* substituted, and unsupported capabilities fail with a typed error.
|
||||
*/
|
||||
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||
private state: HarnessSessionState = 'idle';
|
||||
private processId: string | undefined;
|
||||
private readonly attachedClientIds = new Set<string>();
|
||||
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly conversationId: string,
|
||||
private readonly nativeSessionId: string,
|
||||
private readonly seatId: string,
|
||||
private selection: HarnessSelection,
|
||||
private readonly correlationId: string,
|
||||
private readonly capabilities: readonly HarnessCapability[],
|
||||
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||
) {
|
||||
this.processId = `process-${nativeSessionId}`;
|
||||
}
|
||||
|
||||
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
nativeSessionId: this.nativeSessionId,
|
||||
processId: this.processId,
|
||||
seatId: this.seatId,
|
||||
selection: this.selection,
|
||||
state: this.state,
|
||||
attachedClientIds: [...this.attachedClientIds],
|
||||
};
|
||||
}
|
||||
|
||||
async attach(input: AttachClient): Promise<void> {
|
||||
this.attachedClientIds.add(input.clientId);
|
||||
}
|
||||
|
||||
async detach(clientId: string): Promise<void> {
|
||||
// Removes the browser attachment only; the process and native session persist.
|
||||
this.attachedClientIds.delete(clientId);
|
||||
}
|
||||
|
||||
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
turnId: input.turnId,
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: this.selection,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||
if (!entry) {
|
||||
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
this.selection = selection;
|
||||
return this.selection;
|
||||
}
|
||||
|
||||
async abort(_turnId: string): Promise<void> {
|
||||
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||
}
|
||||
|
||||
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||
if (!this.capabilities.includes('extensionUi')) {
|
||||
throw operationError(
|
||||
'interaction_unsupported',
|
||||
'This harness does not support interactive responses.',
|
||||
this.selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
events(listener: (event: HarnessEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Stop the process but keep the resumable native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'evicted';
|
||||
}
|
||||
|
||||
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Destructively end the native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'ended';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||
* two paths are observably distinct.
|
||||
*/
|
||||
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||
readonly id: string;
|
||||
private readonly capabilities: readonly HarnessCapability[];
|
||||
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||
private createdCount = 0;
|
||||
|
||||
constructor(options: FakeHarnessAdapterOptions) {
|
||||
this.id = options.id;
|
||||
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||
}
|
||||
|
||||
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||
return {
|
||||
id: this.id,
|
||||
displayName: `Fake harness (${this.id})`,
|
||||
capabilities: this.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||
return {
|
||||
harnessId: this.id,
|
||||
version: '1.0.0',
|
||||
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||
models: this.catalogEntries,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||
this.createdCount += 1;
|
||||
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
|
||||
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
input.nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user