249 lines
7.3 KiB
TypeScript
249 lines
7.3 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|