feat(gateway): harness registry, fake adapter, no-substitution suite (P3 Slice Zero, Task 2) #1168
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistrationError,
|
||||||
|
HarnessRegistry,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
describe('HarnessRegistry', () => {
|
||||||
|
it('registers and looks up an adapter by harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
|
||||||
|
registry.register(adapter);
|
||||||
|
|
||||||
|
expect(registry.get('fake')).toBe(adapter);
|
||||||
|
expect(registry.has('fake')).toBe(true);
|
||||||
|
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a blank adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||||
|
expect(registry.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||||
|
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||||
|
// The original registration is untouched.
|
||||||
|
expect(registry.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.get('missing');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||||
|
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||||
|
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||||
|
expect(registry.has('missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessErrorCode,
|
||||||
|
HarnessErrorDto,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||||
|
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||||
|
* tuple — there is no field through which a substituted "effective" selection
|
||||||
|
* could ever be reported.
|
||||||
|
*/
|
||||||
|
export class HarnessOperationError extends Error {
|
||||||
|
readonly code: HarnessErrorCode;
|
||||||
|
readonly dto: HarnessErrorDto;
|
||||||
|
|
||||||
|
constructor(dto: HarnessErrorDto) {
|
||||||
|
super(dto.message);
|
||||||
|
this.name = 'HarnessOperationError';
|
||||||
|
this.code = dto.code;
|
||||||
|
this.dto = dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||||
|
export function operationError(
|
||||||
|
code: HarnessErrorCode,
|
||||||
|
message: string,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
correlationId: string,
|
||||||
|
retryable = false,
|
||||||
|
): HarnessOperationError {
|
||||||
|
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||||
|
export class HarnessAdapterUnavailableError extends Error {
|
||||||
|
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||||
|
|
||||||
|
constructor(readonly harnessId: string) {
|
||||||
|
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||||
|
this.name = 'HarnessAdapterUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||||
|
|
||||||
|
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||||
|
export class HarnessRegistrationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly reason: HarnessRegistrationFailure,
|
||||||
|
readonly harnessId: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
reason === 'blank_id'
|
||||||
|
? 'A harness adapter id must be a non-empty string.'
|
||||||
|
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||||
|
);
|
||||||
|
this.name = 'HarnessRegistrationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||||
|
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||||
|
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessRegistry {
|
||||||
|
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||||
|
|
||||||
|
register(adapter: HarnessAdapter): void {
|
||||||
|
const id = adapter.id;
|
||||||
|
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||||
|
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||||
|
}
|
||||||
|
if (this.adapters.has(id)) {
|
||||||
|
throw new HarnessRegistrationError('duplicate_id', id);
|
||||||
|
}
|
||||||
|
this.adapters.set(id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(harnessId: string): HarnessAdapter {
|
||||||
|
const adapter = this.adapters.get(harnessId);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new HarnessAdapterUnavailableError(harnessId);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(harnessId: string): boolean {
|
||||||
|
return this.adapters.has(harnessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): readonly HarnessAdapter[] {
|
||||||
|
return [...this.adapters.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted, server-derived authority. In production this is produced by the
|
||||||
|
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||||
|
*/
|
||||||
|
export interface TrustedGatewayScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||||
|
const FORBIDDEN_REQUEST_FIELDS = [
|
||||||
|
'actorId',
|
||||||
|
'tenantId',
|
||||||
|
'correlationId',
|
||||||
|
'seatId',
|
||||||
|
'seat',
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||||
|
export class HarnessScopeViolationError extends Error {
|
||||||
|
constructor(readonly field: string) {
|
||||||
|
super(`External request supplied server-authority field "${field}".`);
|
||||||
|
this.name = 'HarnessScopeViolationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvictSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespondInteractionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly response: HarnessInteractionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSession {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly handle: HarnessSessionHandle;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||||
|
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||||
|
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||||
|
* lifecycle operations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessService {
|
||||||
|
private readonly sessions = new Map<string, ActiveSession>();
|
||||||
|
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: CreateHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.create({ context, conversationId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: ResumeHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, nativeSessionId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: AttachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.attach({ clientId: request.clientId });
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: DetachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.detach(request.clientId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async evict(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EvictSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.evictProcess(request.reason);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EndSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.endSession(request.reason);
|
||||||
|
const snapshot = await handle.snapshot();
|
||||||
|
this.sessions.delete(request.conversationId);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: RespondInteractionRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.respondInteraction(request.response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
const handle = this.requireHandle(scope, conversationId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||||
|
try {
|
||||||
|
return this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
catalogPromise: Promise<HarnessCatalog>,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const catalog = await catalogPromise;
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||||
|
const active = this.sessions.get(conversationId);
|
||||||
|
if (!active) {
|
||||||
|
throw operationError(
|
||||||
|
'session_not_found',
|
||||||
|
`No active harness session for conversation "${conversationId}".`,
|
||||||
|
{ harnessId: '', providerId: '', modelId: '' },
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return active.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||||
|
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.seatId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject any request object that carries a server-authority field. */
|
||||||
|
function assertTrustedRequest(request: object): void {
|
||||||
|
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||||
|
throw new HarnessScopeViolationError(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the typed operation error so callers importing from the service
|
||||||
|
// have the discriminated failure type without reaching into the registry.
|
||||||
|
export { HarnessOperationError } from './harness.registry.js';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||||
|
*
|
||||||
|
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||||
|
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||||
|
*/
|
||||||
|
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||||
|
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||||
|
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||||
@@ -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