Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c12bdfcb | ||
|
|
30a694358d | ||
|
|
e01dfa0cd7 | ||
|
|
6c4a2eb626 |
@@ -21,7 +21,6 @@ import { AdminModule } from './admin/admin.module.js';
|
|||||||
import { CommandsModule } from './commands/commands.module.js';
|
import { CommandsModule } from './commands/commands.module.js';
|
||||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||||
import { GCModule } from './gc/gc.module.js';
|
import { GCModule } from './gc/gc.module.js';
|
||||||
import { HarnessModule } from './harness/harness.module.js';
|
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
@@ -61,7 +60,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
HarnessModule,
|
|
||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { CoordModule } from './coord.module.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
|
|
||||||
describe('CoordModule DI (compiled-metadata boot)', () => {
|
|
||||||
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue({ canActivate: (): boolean => true })
|
|
||||||
.compile();
|
|
||||||
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
|
||||||
InteractionCoordinationService,
|
|
||||||
);
|
|
||||||
await moduleRef.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
InteractionCoordinationClient,
|
InteractionCoordinationClient,
|
||||||
type CoordinationObservation,
|
type CoordinationObservation,
|
||||||
@@ -13,7 +13,6 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||||
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
|
||||||
|
|
||||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||||
@@ -61,8 +60,6 @@ export class InteractionCoordinationService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||||
@Optional()
|
|
||||||
@Inject(HANDOFF_ID_FACTORY)
|
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import {
|
|
||||||
type CanActivate,
|
|
||||||
type ExecutionContext,
|
|
||||||
type INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
|
||||||
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
|
||||||
import { HarnessModule } from './harness.module.js';
|
|
||||||
|
|
||||||
// A known-available tuple from the fake adapter's default catalog.
|
|
||||||
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
|
||||||
// A tuple whose provider/model are not in any catalog.
|
|
||||||
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
|
||||||
// A tuple that is known in the catalog but flagged unavailable.
|
|
||||||
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function registryWithFake(): HarnessRegistry {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Harness selection HTTP surface', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
let repository: HarnessSelectionRepository;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [HarnessModule],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.overrideProvider(HARNESS_REGISTRY)
|
|
||||||
.useValue(registryWithFake())
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
// Real in-memory repository from the module graph — proves the module wired it.
|
|
||||||
repository = moduleRef.get(HarnessSelectionRepository);
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
|
||||||
);
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset owner-scoped state between tests via the public API surface.
|
|
||||||
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.get('/api/chat/preferences/selection')
|
|
||||||
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
// The returned selection is user-1's (guard-derived scope), not the query's.
|
|
||||||
expect(response.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
|
||||||
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
|
||||||
|
|
||||||
const put = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(next)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
expect(put.status).toBe(200);
|
|
||||||
expect(put.body.selection).toEqual(next);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.status).toBe(200);
|
|
||||||
expect(get.body.selection).toEqual(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send({ selection: 'gpt-4o' })
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
|
||||||
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
|
||||||
['userId', { ...VALID, userId: 'attacker' }],
|
|
||||||
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
|
||||||
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
|
||||||
['home', { ...VALID, home: '/home/attacker' }],
|
|
||||||
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
|
||||||
])(
|
|
||||||
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
|
||||||
async (_name, body) => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(body)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(UNKNOWN)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
expect(response.body.code).toBe('selection_invalid');
|
|
||||||
// Echoed back unchanged: no first-row / first-provider substitution.
|
|
||||||
expect(response.body.selection).toEqual(UNKNOWN);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
|
||||||
const response = await request(app.getHttpServer())
|
|
||||||
.put('/api/chat/preferences/selection')
|
|
||||||
.send(UNAVAILABLE)
|
|
||||||
.set('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
expect(response.body.code).toBe('model_unavailable');
|
|
||||||
expect(response.body.selection).toEqual(UNAVAILABLE);
|
|
||||||
|
|
||||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
|
||||||
expect(get.body.selection).toEqual(VALID);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { HarnessOperationError } from './harness.registry.js';
|
|
||||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
|
||||||
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
|
||||||
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
|
||||||
* query string can never name another user, tenant, or seat. A typed selection
|
|
||||||
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
|
||||||
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
|
||||||
* unchanged, and never mutates the stored selection.
|
|
||||||
*/
|
|
||||||
@Controller('api/chat/preferences/selection')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class HarnessSelectionController {
|
|
||||||
constructor(private readonly selection: HarnessSelectionService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
|
||||||
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put()
|
|
||||||
async put(
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Body() dto: HarnessSelectionInputDto,
|
|
||||||
): Promise<SelectionResponseDto> {
|
|
||||||
try {
|
|
||||||
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
|
||||||
harnessId: dto.harnessId,
|
|
||||||
providerId: dto.providerId,
|
|
||||||
modelId: dto.modelId,
|
|
||||||
});
|
|
||||||
return { selection: stored };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessOperationError) {
|
|
||||||
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,90 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import type { HarnessSelection } from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
import {
|
|
||||||
HarnessAdapterUnavailableError,
|
|
||||||
HarnessRegistry,
|
|
||||||
operationError,
|
|
||||||
} from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { readContextFromScope } from './harness.dto.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
|
||||||
* requested harness/provider/model tuple against the live catalog with NO
|
|
||||||
* fallback substitution, then persists it owner-scoped. The stored selection is
|
|
||||||
* only ever mutated when the tuple is valid AND available.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class HarnessSelectionService {
|
|
||||||
constructor(
|
|
||||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
|
||||||
private readonly repository: HarnessSelectionRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
|
||||||
return this.repository.get(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSelection(
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<HarnessSelection> {
|
|
||||||
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
|
||||||
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
|
||||||
await this.assertSelectionAvailable(scope, selection);
|
|
||||||
return this.repository.set(scope, selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertSelectionAvailable(
|
|
||||||
scope: ActorTenantScope,
|
|
||||||
selection: HarnessSelection,
|
|
||||||
): Promise<void> {
|
|
||||||
const correlationId = randomUUID();
|
|
||||||
|
|
||||||
let adapter;
|
|
||||||
try {
|
|
||||||
adapter = this.registry.get(selection.harnessId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessAdapterUnavailableError) {
|
|
||||||
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalog = await adapter.catalog(readContextFromScope(scope));
|
|
||||||
const entry = catalog.models.find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.harnessId === selection.harnessId &&
|
|
||||||
candidate.providerId === selection.providerId &&
|
|
||||||
candidate.modelId === selection.modelId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
|
||||||
throw operationError(
|
|
||||||
'selection_invalid',
|
|
||||||
'The requested harness/provider/model tuple is not in the catalog.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (entry.availability === 'unavailable') {
|
|
||||||
throw operationError(
|
|
||||||
'model_unavailable',
|
|
||||||
'The requested model is currently unavailable.',
|
|
||||||
selection,
|
|
||||||
correlationId,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import {
|
|
||||||
type CanActivate,
|
|
||||||
type ExecutionContext,
|
|
||||||
type INestApplication,
|
|
||||||
ValidationPipe,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
|
||||||
// The real module under test — importing it (not a hand-listed controllers/mocks
|
|
||||||
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
|
||||||
import { HarnessModule } from './harness.module.js';
|
|
||||||
|
|
||||||
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
|
||||||
const FORBIDDEN_KEYS = [
|
|
||||||
'executable',
|
|
||||||
'executablePath',
|
|
||||||
'home',
|
|
||||||
'homeDir',
|
|
||||||
'cwd',
|
|
||||||
'workingDir',
|
|
||||||
'workingDirectory',
|
|
||||||
'nativeSessionPath',
|
|
||||||
'sessionPath',
|
|
||||||
'env',
|
|
||||||
'secret',
|
|
||||||
'secrets',
|
|
||||||
'token',
|
|
||||||
'apiKey',
|
|
||||||
];
|
|
||||||
|
|
||||||
function assertNoForbiddenLeak(payload: unknown): void {
|
|
||||||
const serialized = JSON.stringify(payload).toLowerCase();
|
|
||||||
for (const key of FORBIDDEN_KEYS) {
|
|
||||||
expect(serialized).not.toContain(key.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
|
||||||
requestContext.user = { id: 'user-1' };
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function registryWithFake(): HarnessRegistry {
|
|
||||||
const registry = new HarnessRegistry();
|
|
||||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Harness catalog HTTP surface', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [HarnessModule],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.overrideProvider(HARNESS_REGISTRY)
|
|
||||||
.useValue(registryWithFake())
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
app.useGlobalPipes(
|
|
||||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
|
||||||
);
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
|
||||||
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
|
||||||
// have thrown and this suite would never reach here.
|
|
||||||
expect(app).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(Array.isArray(response.body)).toBe(true);
|
|
||||||
expect(response.body.length).toBeGreaterThan(0);
|
|
||||||
const summary = response.body[0];
|
|
||||||
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
|
||||||
expect(summary.id).toBe('fake');
|
|
||||||
expect(typeof summary.displayName).toBe('string');
|
|
||||||
expect(Array.isArray(summary.capabilities)).toBe(true);
|
|
||||||
assertNoForbiddenLeak(response.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.harnessId).toBe('fake');
|
|
||||||
expect(typeof response.body.version).toBe('string');
|
|
||||||
expect(typeof response.body.fingerprint).toBe('string');
|
|
||||||
expect(Array.isArray(response.body.models)).toBe(true);
|
|
||||||
expect(response.body.models.length).toBeGreaterThan(0);
|
|
||||||
const entry = response.body.models[0];
|
|
||||||
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
|
||||||
expect(Object.keys(entry).sort()).toEqual(
|
|
||||||
[
|
|
||||||
'authState',
|
|
||||||
'availability',
|
|
||||||
'displayName',
|
|
||||||
'harnessId',
|
|
||||||
'inputTypes',
|
|
||||||
'modelId',
|
|
||||||
'providerId',
|
|
||||||
'reasoningCapability',
|
|
||||||
].sort(),
|
|
||||||
);
|
|
||||||
assertNoForbiddenLeak(response.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body.code).toBe('adapter_unavailable');
|
|
||||||
// A fallback catalog would carry a models array; a typed error must not.
|
|
||||||
expect(response.body.models).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
|
||||||
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
||||||
import {
|
|
||||||
readContextFromScope,
|
|
||||||
toHarnessSummary,
|
|
||||||
toSafeCatalog,
|
|
||||||
type HarnessCatalogDto,
|
|
||||||
type HarnessSummaryDto,
|
|
||||||
} from './harness.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
|
||||||
* fields (identity, capabilities, provider/model catalog) — never executables,
|
|
||||||
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
|
||||||
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
|
||||||
*/
|
|
||||||
@Controller('api/harnesses')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class HarnessController {
|
|
||||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
|
||||||
const context = readContextFromScope(scopeFromUser(user));
|
|
||||||
const summaries: HarnessSummaryDto[] = [];
|
|
||||||
for (const adapter of this.registry.list()) {
|
|
||||||
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
|
||||||
}
|
|
||||||
return summaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':harnessId/catalog')
|
|
||||||
async catalog(
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Param('harnessId') harnessId: string,
|
|
||||||
): Promise<HarnessCatalogDto> {
|
|
||||||
const context = readContextFromScope(scopeFromUser(user));
|
|
||||||
let adapter;
|
|
||||||
try {
|
|
||||||
adapter = this.registry.get(harnessId);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HarnessAdapterUnavailableError) {
|
|
||||||
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
|
||||||
throw new HttpException(
|
|
||||||
{ code: error.code, message: error.message, harnessId },
|
|
||||||
HttpStatus.NOT_FOUND,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return toSafeCatalog(await adapter.catalog(context));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
import type {
|
|
||||||
HarnessActorContext,
|
|
||||||
HarnessAuthState,
|
|
||||||
HarnessCapability,
|
|
||||||
HarnessCatalog,
|
|
||||||
HarnessCatalogEntry,
|
|
||||||
HarnessDescriptor,
|
|
||||||
HarnessInputType,
|
|
||||||
HarnessModelAvailability,
|
|
||||||
HarnessSelection,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
|
||||||
*
|
|
||||||
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
|
||||||
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
|
||||||
* any extra property — including smuggled server-authority fields such as
|
|
||||||
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
|
||||||
* is rejected with 400. There is deliberately no field through which a caller can
|
|
||||||
* name a scope; scope is derived on the server from the authenticated session.
|
|
||||||
*/
|
|
||||||
export class HarnessSelectionInputDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
harnessId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
providerId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
modelId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe harness summary — identity and capabilities only. */
|
|
||||||
export interface HarnessSummaryDto {
|
|
||||||
readonly id: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly capabilities: readonly HarnessCapability[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
|
||||||
export interface HarnessCatalogEntryDto {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly providerId: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly reasoningCapability: boolean;
|
|
||||||
readonly inputTypes: readonly HarnessInputType[];
|
|
||||||
readonly authState: HarnessAuthState;
|
|
||||||
readonly availability: HarnessModelAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Browser-safe catalog envelope. */
|
|
||||||
export interface HarnessCatalogDto {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly version: string;
|
|
||||||
readonly fingerprint: string;
|
|
||||||
readonly models: readonly HarnessCatalogEntryDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response envelope for the caller's current selection (null when unset). */
|
|
||||||
export interface SelectionResponseDto {
|
|
||||||
readonly selection: HarnessSelection | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
|
||||||
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
|
||||||
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
|
||||||
*/
|
|
||||||
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
|
||||||
return {
|
|
||||||
actorId: scope.userId,
|
|
||||||
tenantId: scope.tenantId,
|
|
||||||
seatId: scope.userId,
|
|
||||||
correlationId: randomUUID(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
|
||||||
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
|
||||||
return {
|
|
||||||
id: descriptor.id,
|
|
||||||
displayName: descriptor.displayName,
|
|
||||||
capabilities: [...descriptor.capabilities],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
|
||||||
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
|
||||||
return {
|
|
||||||
harnessId: catalog.harnessId,
|
|
||||||
version: catalog.version,
|
|
||||||
fingerprint: catalog.fingerprint,
|
|
||||||
models: catalog.models.map(toSafeCatalogEntry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
|
||||||
return {
|
|
||||||
harnessId: entry.harnessId,
|
|
||||||
providerId: entry.providerId,
|
|
||||||
modelId: entry.modelId,
|
|
||||||
displayName: entry.displayName,
|
|
||||||
reasoningCapability: entry.reasoningCapability,
|
|
||||||
inputTypes: [...entry.inputTypes],
|
|
||||||
authState: entry.authState,
|
|
||||||
availability: entry.availability,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { HarnessRegistry } from './harness.registry.js';
|
|
||||||
import { HarnessService } from './harness.service.js';
|
|
||||||
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
|
|
||||||
import { HarnessController } from './harness.controller.js';
|
|
||||||
import { HarnessSelectionController } from './harness-selection.controller.js';
|
|
||||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
|
||||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wires the harness-neutral registry/service (Task Two) together with the
|
|
||||||
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
|
||||||
*
|
|
||||||
* The registry is provided empty here; real harness adapters are registered in a
|
|
||||||
* later task. Because the controllers/services resolve their collaborators through
|
|
||||||
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
|
||||||
*/
|
|
||||||
@Module({
|
|
||||||
controllers: [HarnessController, HarnessSelectionController],
|
|
||||||
providers: [
|
|
||||||
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
|
|
||||||
{ provide: HARNESS_SERVICE, useClass: HarnessService },
|
|
||||||
HarnessSelectionRepository,
|
|
||||||
HarnessSelectionService,
|
|
||||||
],
|
|
||||||
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
|
|
||||||
})
|
|
||||||
export class HarnessModule {}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,7 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
|||||||
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
|
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
|
||||||
| QA / test strategy | `guides/QA-TESTING.md` |
|
| QA / test strategy | `guides/QA-TESTING.md` |
|
||||||
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
|
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
|
||||||
|
| Writing style (docs, comms, any prose) | `guides/WRITING-STYLE.md` |
|
||||||
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
||||||
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
||||||
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ Master/slave model:
|
|||||||
- Do not perform destructive git/file actions without explicit instruction.
|
- Do not perform destructive git/file actions without explicit instruction.
|
||||||
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
||||||
|
|
||||||
|
### Output standards (writing + code)
|
||||||
|
|
||||||
|
- Technical documentation follows **MOS-STE** (Mosaic Simplified Technical English — an adapted ASD-STE100 profile): short sentences, one instruction per sentence, active voice, one word per meaning, one term per concept. Full rules: `~/.config/mosaic/guides/WRITING-STYLE.md`.
|
||||||
|
- Apply MOS-STE **hardest to verification artifacts** (acceptance criteria, witness predicates, gate/alarm conditions). There an ambiguous term produces a false green, not just a confused reader.
|
||||||
|
- Source code follows the **Google Style Guide** for the language.
|
||||||
|
- User-facing comms follow the user's declared `communicationStyle` in `USER.md` "Communication Preferences" (`direct` | `friendly` | `formal`, default `direct`); `guides/WRITING-STYLE.md` §5 maps each value to output. The documentation standard does not change with user preference.
|
||||||
|
- **Carve-out:** MOS-STE does NOT apply to content that must carry a specific human voice (letters, personal or marketing prose, voice-matched output). A declared voice profile wins.
|
||||||
|
|
||||||
### Secrets handling (HARD RULE)
|
### Secrets handling (HARD RULE)
|
||||||
|
|
||||||
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Writing Style Standard — MOS-STE (MANDATORY)
|
||||||
|
|
||||||
|
This guide defines how agents write. It sets one style standard per output type.
|
||||||
|
It is written in the standard it defines, as a worked example.
|
||||||
|
|
||||||
|
**Adapted, not compliant.** MOS-STE (Mosaic Simplified Technical English) is an
|
||||||
|
adapted profile of ASD-STE100. Mosaic does not license or certify against
|
||||||
|
ASD-STE100. Mosaic uses the load-bearing rules and fits them to agent work. This
|
||||||
|
is the same stance Mosaic takes toward DO-178B/C: use the rigor, do not claim the
|
||||||
|
certification.
|
||||||
|
|
||||||
|
## Scope — which standard governs which output
|
||||||
|
|
||||||
|
| Output type | Standard |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| Technical documentation (READMEs, runbooks, PRDs, procedures, ADRs, guides, acceptance criteria, design docs) | **MOS-STE** (this guide) |
|
||||||
|
| Source code and code comments | **Google Style Guide** for the language (§4) |
|
||||||
|
| Inter-agent comms | MOS-STE by default (concise, structured) |
|
||||||
|
| User-facing comms | **Per-user style choice** — read `USER.md` "Communication Preferences" (§5) |
|
||||||
|
| End-user prose the user owns (marketing, letters, personal writing, voice-matched content) | The user's declared voice. MOS-STE does NOT apply. |
|
||||||
|
|
||||||
|
**The user-voice carve-out is absolute.** Do not apply MOS-STE to content that
|
||||||
|
must carry a specific human voice (for example a cover letter, a personal
|
||||||
|
message, or marketing copy). That content needs the user's voice. MOS-STE would
|
||||||
|
damage it. When a project declares a voice profile, that profile wins.
|
||||||
|
|
||||||
|
## 1. Why one standard
|
||||||
|
|
||||||
|
Agent documentation drifts across projects. Different agents use different terms,
|
||||||
|
sentence styles, and structures for the same concept. Readers lose time.
|
||||||
|
Assumptions hide in ambiguous prose. One standard gives agents a clear target. It
|
||||||
|
gives reviewers a clear test.
|
||||||
|
|
||||||
|
## 2. Where MOS-STE matters most — verification artifacts
|
||||||
|
|
||||||
|
Apply MOS-STE hardest to acceptance criteria, witness predicates, gate
|
||||||
|
definitions, and alarm conditions. In prose, an ambiguous term produces a
|
||||||
|
confused reader. In a verification artifact, an ambiguous term produces a false
|
||||||
|
green — a check that passes without testing the claim.
|
||||||
|
|
||||||
|
The one-term-one-concept rule (rule 9) is the guard. When one word names two
|
||||||
|
concepts in one predicate, the check can test the wrong concept and still pass.
|
||||||
|
|
||||||
|
**Worked failure.** A rename used a witness predicate with three clauses: ref A
|
||||||
|
present, ref B absent, tip committed from this host. Every clause tested the git
|
||||||
|
_ref_ (the channel). The claim under test was about a _field inside the payload_.
|
||||||
|
The word "beacon" named two concepts in one sentence. Deleting ref B was the next
|
||||||
|
scheduled step. That step flips the last clause green and certifies a state in
|
||||||
|
which the payload still names the wrong host. The predicate was one planned action
|
||||||
|
away from a false green on its normal path. The payload field was never tested.
|
||||||
|
|
||||||
|
Rule: when N failure modes share one observable, the observable is not a
|
||||||
|
diagnostic. In a verification artifact, that ambiguity does not confuse a reader —
|
||||||
|
it certifies the defect.
|
||||||
|
|
||||||
|
## 3. MOS-STE rules
|
||||||
|
|
||||||
|
### 3.1 Sentence rules
|
||||||
|
|
||||||
|
1. Keep sentences short. Use 20 words or fewer for a procedure. Use 25 words or
|
||||||
|
fewer for a description. (Reasoning and doctrine prose relaxes this limit —
|
||||||
|
see §3.4. A future lint enforces §3.1, not §3.4.)
|
||||||
|
2. Write one instruction per sentence. In a procedure, give one command per step.
|
||||||
|
3. Use the active voice. Write "Run the script." Do not write "The script should
|
||||||
|
be run."
|
||||||
|
4. Use the imperative for instructions. Start the sentence with the verb.
|
||||||
|
5. Use simple verb tenses. Prefer the present tense. Avoid the perfect and
|
||||||
|
progressive tenses when a simple tense works.
|
||||||
|
6. Do not use an `-ing` form when it makes the meaning unclear.
|
||||||
|
7. Write positive statements. State what to do, not only what to avoid.
|
||||||
|
|
||||||
|
### 3.2 Word rules
|
||||||
|
|
||||||
|
8. Use one word for one meaning. Do not use the same word in two senses.
|
||||||
|
9. Use one term for one concept. Do not use synonyms for variety. Example: choose
|
||||||
|
`secret`, `credential`, or `key` for each concept, and keep it.
|
||||||
|
10. Use articles (`a`, `the`). Do not drop words to save space.
|
||||||
|
11. Keep an approved-terms glossary per project. Add each domain noun and each
|
||||||
|
chosen verb. Technical names (for example `Vault`, `cgroup`, `systemd`) are
|
||||||
|
always allowed.
|
||||||
|
12. Define an abbreviation at its first use. Then use it consistently.
|
||||||
|
|
||||||
|
### 3.3 Structure rules
|
||||||
|
|
||||||
|
13. Use a list for parallel items or sequential steps. Do not put them in one long
|
||||||
|
sentence.
|
||||||
|
14. Use a table for data with more than two dimensions.
|
||||||
|
15. Use parallel structure in headings and steps.
|
||||||
|
16. Repeat the noun. Do not use a pronoun when the reference is unclear.
|
||||||
|
|
||||||
|
### 3.4 Adaptation notes (where MOS-STE deviates from ASD-STE100, and why)
|
||||||
|
|
||||||
|
- **No licensed dictionary.** ASD-STE100 ships a controlled dictionary under
|
||||||
|
copyright. MOS-STE uses per-project glossaries instead (rule 11).
|
||||||
|
- **Domain terms are allowed.** MOS-STE keeps every term the work needs.
|
||||||
|
- **Reasoning prose gets structure, not amputation.** Apply the sentence and word
|
||||||
|
rules to design and doctrine writing. Allow the length a subtle argument needs.
|
||||||
|
Readable-first beats rule-strict when the two conflict.
|
||||||
|
|
||||||
|
## 4. Code — Google Style Guide
|
||||||
|
|
||||||
|
Write source code to the Google Style Guide for the language (Python, TypeScript,
|
||||||
|
Shell, Go, and so on). Match the existing file when a local convention already
|
||||||
|
exists. Keep code comments to the MOS-STE sentence and word rules.
|
||||||
|
|
||||||
|
## 5. User-facing comms — a per-user choice
|
||||||
|
|
||||||
|
Mosaic is multi-user. Different users want different comms styles. The framework
|
||||||
|
already carries the selectable setting: `communicationStyle` (`direct` |
|
||||||
|
`friendly` | `formal`, default `direct`). `mosaic init` writes it, and the
|
||||||
|
builder renders it into the generated `USER.md` "Communication Preferences"
|
||||||
|
section. This guide adds the OUTPUT meaning of each value; do not invent new
|
||||||
|
values.
|
||||||
|
|
||||||
|
The builder renders the style as prose bullets, not the token name, so match on
|
||||||
|
the leading bullet the generated `USER.md` actually contains:
|
||||||
|
|
||||||
|
| `USER.md` leading bullet | Style | User-facing output |
|
||||||
|
| ----------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| "Direct and concise" | `direct` (default) | MOS-STE structure — short, active, defined terms, tables for overview. |
|
||||||
|
| "Warm and conversational" | `friendly` | Warmer register. Full sentences, explain reasoning, fewer tables. |
|
||||||
|
| "Professional and structured" | `formal` | Professional and structured. Thorough, with explicit recommendations. |
|
||||||
|
|
||||||
|
This setting governs **user-facing comms only**. It does not change the
|
||||||
|
documentation standard (§3), which is always MOS-STE regardless of the value.
|
||||||
|
|
||||||
|
## 6. Enforcement
|
||||||
|
|
||||||
|
- **Now:** human review only. **No mechanical prose check exists today.** The
|
||||||
|
pre-push gate runs typecheck, lint, build, and tests; it inspects no prose.
|
||||||
|
Reviewers check output against the scope table and the MOS-STE rules by hand.
|
||||||
|
- **Future:** an MOS-STE lint check (built from the §3.1 sentence rules) and a
|
||||||
|
Google-style linter in the pre-push gate. A future linter enforces §3.1, not
|
||||||
|
§3.4 — see the note at rule 1.
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
||||||
import { HARNESS_CAPABILITIES, HARNESS_ERROR_CODES } from './index.js';
|
|
||||||
import type {
|
|
||||||
AttachConversation,
|
|
||||||
ConversationSnapshot,
|
|
||||||
CreateHarnessSession,
|
|
||||||
HarnessAdapter,
|
|
||||||
HarnessCapability,
|
|
||||||
HarnessConversationService,
|
|
||||||
HarnessDescriptor,
|
|
||||||
HarnessError,
|
|
||||||
HarnessErrorCode,
|
|
||||||
HarnessEvent,
|
|
||||||
HarnessEventEnvelope,
|
|
||||||
HarnessInteractionState,
|
|
||||||
HarnessPrompt,
|
|
||||||
HarnessPromptReceipt,
|
|
||||||
HarnessSelection,
|
|
||||||
HarnessSessionHandle,
|
|
||||||
HarnessSessionSnapshot,
|
|
||||||
ResumeHarnessSession,
|
|
||||||
SendHarnessTurn,
|
|
||||||
TurnReceipt,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
const EXPECTED_CAPABILITIES = [
|
|
||||||
'modelSelection',
|
|
||||||
'thinkingLevels',
|
|
||||||
'images',
|
|
||||||
'toolEvents',
|
|
||||||
'extensionUi',
|
|
||||||
'steering',
|
|
||||||
'followUp',
|
|
||||||
'compaction',
|
|
||||||
'persistentResume',
|
|
||||||
] as const satisfies readonly HarnessCapability[];
|
|
||||||
|
|
||||||
const EXPECTED_ERROR_CODES = [
|
|
||||||
'auth_required',
|
|
||||||
'selection_invalid',
|
|
||||||
'catalog_unavailable',
|
|
||||||
'catalog_stale',
|
|
||||||
'model_unavailable',
|
|
||||||
'no_viable_provider',
|
|
||||||
'session_create_failed',
|
|
||||||
'session_not_found',
|
|
||||||
'resume_conflict',
|
|
||||||
'session_busy',
|
|
||||||
'auth_bundle_concurrency_unverified',
|
|
||||||
'adapter_unavailable',
|
|
||||||
'sandbox_unavailable',
|
|
||||||
'rpc_version_unsupported',
|
|
||||||
'rpc_protocol_error',
|
|
||||||
'process_exited',
|
|
||||||
'outcome_unknown',
|
|
||||||
'interaction_unsupported',
|
|
||||||
'aborted',
|
|
||||||
] as const satisfies readonly HarnessErrorCode[];
|
|
||||||
|
|
||||||
function assertNever(value: never): never {
|
|
||||||
throw new Error(`Unexpected contract variant: ${JSON.stringify(value)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeEvent(event: HarnessEvent): string {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'session.started':
|
|
||||||
case 'session.state':
|
|
||||||
case 'session.identity_changed':
|
|
||||||
case 'turn.started':
|
|
||||||
case 'text.delta':
|
|
||||||
case 'thinking.delta':
|
|
||||||
case 'tool.started':
|
|
||||||
case 'tool.updated':
|
|
||||||
case 'tool.finished':
|
|
||||||
case 'interaction.required':
|
|
||||||
case 'usage.updated':
|
|
||||||
case 'turn.completed':
|
|
||||||
case 'error':
|
|
||||||
return event.type;
|
|
||||||
default:
|
|
||||||
return assertNever(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeError(error: HarnessError): HarnessErrorCode {
|
|
||||||
switch (error.code) {
|
|
||||||
case 'auth_required':
|
|
||||||
case 'selection_invalid':
|
|
||||||
case 'catalog_unavailable':
|
|
||||||
case 'catalog_stale':
|
|
||||||
case 'model_unavailable':
|
|
||||||
case 'no_viable_provider':
|
|
||||||
case 'session_create_failed':
|
|
||||||
case 'session_not_found':
|
|
||||||
case 'resume_conflict':
|
|
||||||
case 'session_busy':
|
|
||||||
case 'auth_bundle_concurrency_unverified':
|
|
||||||
case 'adapter_unavailable':
|
|
||||||
case 'sandbox_unavailable':
|
|
||||||
case 'rpc_version_unsupported':
|
|
||||||
case 'rpc_protocol_error':
|
|
||||||
case 'process_exited':
|
|
||||||
case 'outcome_unknown':
|
|
||||||
case 'interaction_unsupported':
|
|
||||||
case 'aborted':
|
|
||||||
return error.code;
|
|
||||||
default:
|
|
||||||
return assertNever(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('generic harness contracts', (): void => {
|
|
||||||
it('keeps harness, provider, model, conversation, native session, process, and seat separate', (): void => {
|
|
||||||
const selection = {
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai-codex',
|
|
||||||
modelId: 'gpt-5-codex',
|
|
||||||
} satisfies HarnessSelection;
|
|
||||||
const snapshot = {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
nativeSessionId: 'native-session-1',
|
|
||||||
processId: 'process-1',
|
|
||||||
seatId: 'seat-1',
|
|
||||||
selection,
|
|
||||||
state: 'idle',
|
|
||||||
attachedClientIds: ['browser-1'],
|
|
||||||
} satisfies HarnessSessionSnapshot;
|
|
||||||
|
|
||||||
const identifiers = [
|
|
||||||
snapshot.selection.harnessId,
|
|
||||||
snapshot.selection.providerId,
|
|
||||||
snapshot.selection.modelId,
|
|
||||||
snapshot.conversationId,
|
|
||||||
snapshot.nativeSessionId,
|
|
||||||
snapshot.processId,
|
|
||||||
snapshot.seatId,
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(new Set(identifiers).size).toBe(7);
|
|
||||||
expect(snapshot).toMatchObject({
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
nativeSessionId: 'native-session-1',
|
|
||||||
processId: 'process-1',
|
|
||||||
seatId: 'seat-1',
|
|
||||||
selection,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('advertises the complete capability set as checked literals', (): void => {
|
|
||||||
const descriptor = {
|
|
||||||
id: 'pi',
|
|
||||||
displayName: 'Pi',
|
|
||||||
capabilities: HARNESS_CAPABILITIES,
|
|
||||||
} satisfies HarnessDescriptor;
|
|
||||||
|
|
||||||
expect(HARNESS_CAPABILITIES).toEqual(EXPECTED_CAPABILITIES);
|
|
||||||
expect(descriptor.capabilities).toEqual(EXPECTED_CAPABILITIES);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes every stable error code as an exhaustive discriminated union', (): void => {
|
|
||||||
const selection: HarnessSelection = {
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai-codex',
|
|
||||||
modelId: 'gpt-5-codex',
|
|
||||||
};
|
|
||||||
const error: HarnessError = {
|
|
||||||
code: 'model_unavailable',
|
|
||||||
message: 'The selected model is unavailable.',
|
|
||||||
retryable: true,
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
selection,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(HARNESS_ERROR_CODES).toEqual(EXPECTED_ERROR_CODES);
|
|
||||||
expect(describeError(error)).toBe('model_unavailable');
|
|
||||||
const receipt = {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
turnId: 'turn-1',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
state: 'accepted',
|
|
||||||
selection,
|
|
||||||
} satisfies HarnessPromptReceipt;
|
|
||||||
|
|
||||||
expect(error.selection).toEqual(selection);
|
|
||||||
expect(receipt.selection).toEqual(selection);
|
|
||||||
expect('effectiveSelection' in error).toBe(false);
|
|
||||||
expect('effectiveSelection' in receipt).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wraps every normalized event variant in the persisted envelope', (): void => {
|
|
||||||
const selection: HarnessSelection = {
|
|
||||||
harnessId: 'pi',
|
|
||||||
providerId: 'openai-codex',
|
|
||||||
modelId: 'gpt-5-codex',
|
|
||||||
};
|
|
||||||
const toolStarted: HarnessEvent = {
|
|
||||||
type: 'tool.started',
|
|
||||||
toolCallId: 'tool-call-1',
|
|
||||||
toolName: 'read',
|
|
||||||
};
|
|
||||||
const events: readonly HarnessEvent[] = [
|
|
||||||
{ type: 'session.started', state: 'idle' },
|
|
||||||
{ type: 'session.state', state: 'busy' },
|
|
||||||
{
|
|
||||||
type: 'session.identity_changed',
|
|
||||||
identityGeneration: 2,
|
|
||||||
label: 'Re-enrolled account',
|
|
||||||
},
|
|
||||||
{ type: 'turn.started' },
|
|
||||||
{ type: 'text.delta', text: 'Hello' },
|
|
||||||
{ type: 'thinking.delta', text: 'Reasoning' },
|
|
||||||
toolStarted,
|
|
||||||
{
|
|
||||||
type: 'tool.updated',
|
|
||||||
toolCallId: 'tool-call-1',
|
|
||||||
toolName: 'read',
|
|
||||||
message: 'Reading',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tool.finished',
|
|
||||||
toolCallId: 'tool-call-1',
|
|
||||||
toolName: 'read',
|
|
||||||
isError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'interaction.required',
|
|
||||||
requestId: 'interaction-1',
|
|
||||||
interactionType: 'confirm',
|
|
||||||
state: 'pending',
|
|
||||||
prompt: 'Continue?',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'usage.updated',
|
|
||||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
|
||||||
},
|
|
||||||
{ type: 'turn.completed', outcome: 'settled' },
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
error: {
|
|
||||||
code: 'rpc_protocol_error',
|
|
||||||
message: 'The harness protocol failed.',
|
|
||||||
retryable: false,
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
selection,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const envelope: HarnessEventEnvelope = {
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
nativeSessionId: 'native-session-1',
|
|
||||||
turnId: 'turn-1',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
sequence: 42,
|
|
||||||
nativeEntryCursor: 'native-entry-7',
|
|
||||||
occurredAt: '2026-08-11T12:00:00.000Z',
|
|
||||||
harnessId: 'pi',
|
|
||||||
selection,
|
|
||||||
event: toolStarted,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(events.map(describeEvent)).toEqual([
|
|
||||||
'session.started',
|
|
||||||
'session.state',
|
|
||||||
'session.identity_changed',
|
|
||||||
'turn.started',
|
|
||||||
'text.delta',
|
|
||||||
'thinking.delta',
|
|
||||||
'tool.started',
|
|
||||||
'tool.updated',
|
|
||||||
'tool.finished',
|
|
||||||
'interaction.required',
|
|
||||||
'usage.updated',
|
|
||||||
'turn.completed',
|
|
||||||
'error',
|
|
||||||
]);
|
|
||||||
expect(envelope).toMatchObject({
|
|
||||||
conversationId: 'conversation-1',
|
|
||||||
nativeSessionId: 'native-session-1',
|
|
||||||
turnId: 'turn-1',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
sequence: 42,
|
|
||||||
nativeEntryCursor: 'native-entry-7',
|
|
||||||
harnessId: 'pi',
|
|
||||||
selection,
|
|
||||||
event: toolStarted,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('models the complete one-response interaction lifecycle', (): void => {
|
|
||||||
const states = [
|
|
||||||
'pending',
|
|
||||||
'responded',
|
|
||||||
'cancelled',
|
|
||||||
'expired',
|
|
||||||
] as const satisfies readonly HarnessInteractionState[];
|
|
||||||
|
|
||||||
expect(states).toEqual(['pending', 'responded', 'cancelled', 'expired']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves the approved adapter and conversation method signatures', (): void => {
|
|
||||||
expectTypeOf<HarnessAdapter['create']>().toEqualTypeOf<
|
|
||||||
(input: CreateHarnessSession) => Promise<HarnessSessionHandle>
|
|
||||||
>();
|
|
||||||
expectTypeOf<HarnessAdapter['resume']>().toEqualTypeOf<
|
|
||||||
(input: ResumeHarnessSession) => Promise<HarnessSessionHandle>
|
|
||||||
>();
|
|
||||||
expectTypeOf<HarnessSessionHandle['prompt']>().toEqualTypeOf<
|
|
||||||
(input: HarnessPrompt & { idempotencyKey: string }) => Promise<HarnessPromptReceipt>
|
|
||||||
>();
|
|
||||||
expectTypeOf<HarnessConversationService['attach']>().toEqualTypeOf<
|
|
||||||
(input: AttachConversation & { afterSequence?: number }) => Promise<ConversationSnapshot>
|
|
||||||
>();
|
|
||||||
expectTypeOf<HarnessConversationService['send']>().toEqualTypeOf<
|
|
||||||
(input: SendHarnessTurn & { idempotencyKey: string }) => Promise<TurnReceipt>
|
|
||||||
>();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import type { HarnessCapability, HarnessEvent, HarnessEventEnvelope } from './events.js';
|
|
||||||
|
|
||||||
/** Server-derived actor and seat authority. Browser input must not supply these values. */
|
|
||||||
export interface HarnessActorContext {
|
|
||||||
readonly actorId: string;
|
|
||||||
readonly tenantId: string;
|
|
||||||
readonly seatId: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exact harness/provider/model tuple. These concepts must never be merged into one identifier. */
|
|
||||||
export interface HarnessSelection {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly providerId: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessDescriptor {
|
|
||||||
readonly id: string;
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly capabilities: readonly HarnessCapability[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessInputType = 'text' | 'image';
|
|
||||||
export type HarnessAuthState = 'ready' | 'auth_required' | 'unavailable';
|
|
||||||
export type HarnessModelAvailability = 'available' | 'unavailable';
|
|
||||||
|
|
||||||
export interface HarnessCatalogEntry extends HarnessSelection {
|
|
||||||
readonly displayName: string;
|
|
||||||
readonly reasoningCapability: boolean;
|
|
||||||
readonly thinkingLevels?: readonly string[];
|
|
||||||
readonly inputTypes: readonly HarnessInputType[];
|
|
||||||
readonly contextWindow?: number;
|
|
||||||
readonly authState: HarnessAuthState;
|
|
||||||
readonly availability: HarnessModelAvailability;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessCatalog {
|
|
||||||
readonly harnessId: string;
|
|
||||||
readonly version: string;
|
|
||||||
readonly fingerprint: string;
|
|
||||||
readonly models: readonly HarnessCatalogEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateHarnessSession {
|
|
||||||
readonly context: HarnessActorContext;
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResumeHarnessSession {
|
|
||||||
readonly context: HarnessActorContext;
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly nativeSessionId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessSessionState = 'starting' | 'idle' | 'busy' | 'evicted' | 'ended' | 'failed';
|
|
||||||
|
|
||||||
export interface HarnessSessionSnapshot {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly nativeSessionId: string;
|
|
||||||
/** Absent when the resumable native session has no active process. */
|
|
||||||
readonly processId?: string;
|
|
||||||
readonly seatId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
readonly state: HarnessSessionState;
|
|
||||||
readonly attachedClientIds: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttachClient {
|
|
||||||
readonly clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessPrompt {
|
|
||||||
readonly turnId: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
readonly content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessTurnState =
|
|
||||||
| 'prepared'
|
|
||||||
| 'dispatching'
|
|
||||||
| 'accepted'
|
|
||||||
| 'streaming'
|
|
||||||
| 'settled'
|
|
||||||
| 'failed'
|
|
||||||
| 'aborted'
|
|
||||||
| 'interrupted'
|
|
||||||
| 'outcome_unknown';
|
|
||||||
|
|
||||||
/** A successful receipt reports only the selected tuple; no substitute tuple is representable. */
|
|
||||||
export interface HarnessPromptReceipt {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly turnId: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
readonly state: HarnessTurnState;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessConfirmInteractionResponse {
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly type: 'confirm';
|
|
||||||
readonly accepted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessSelectInteractionResponse {
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly type: 'select';
|
|
||||||
readonly value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessInputInteractionResponse {
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly type: 'input';
|
|
||||||
readonly value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessEditorInteractionResponse {
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly type: 'editor';
|
|
||||||
readonly value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessCancelInteractionResponse {
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly type: 'cancel';
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessInteractionResponse =
|
|
||||||
| HarnessConfirmInteractionResponse
|
|
||||||
| HarnessSelectInteractionResponse
|
|
||||||
| HarnessInputInteractionResponse
|
|
||||||
| HarnessEditorInteractionResponse
|
|
||||||
| HarnessCancelInteractionResponse;
|
|
||||||
|
|
||||||
export type HarnessCloseReason =
|
|
||||||
| 'client_request'
|
|
||||||
| 'idle_timeout'
|
|
||||||
| 'gateway_shutdown'
|
|
||||||
| 'process_crash'
|
|
||||||
| 'composition_changed'
|
|
||||||
| 'session_ended';
|
|
||||||
|
|
||||||
export interface AttachConversation {
|
|
||||||
readonly context: HarnessActorContext;
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly clientId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationSnapshot {
|
|
||||||
readonly session: HarnessSessionSnapshot;
|
|
||||||
readonly lastSequence: number;
|
|
||||||
/** Journal rows replayed after the caller's sequence, never best-effort socket history. */
|
|
||||||
readonly replay: readonly HarnessEventEnvelope[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetachConversation {
|
|
||||||
readonly context: HarnessActorContext;
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendHarnessTurn extends HarnessPrompt {
|
|
||||||
readonly context: HarnessActorContext;
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TurnReceipt extends HarnessPromptReceipt {}
|
|
||||||
|
|
||||||
export interface HarnessAdapter {
|
|
||||||
readonly id: string;
|
|
||||||
describe(context: HarnessActorContext): Promise<HarnessDescriptor>;
|
|
||||||
catalog(context: HarnessActorContext): Promise<HarnessCatalog>;
|
|
||||||
create(input: CreateHarnessSession): Promise<HarnessSessionHandle>;
|
|
||||||
resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessSessionHandle {
|
|
||||||
snapshot(): Promise<HarnessSessionSnapshot>;
|
|
||||||
attach(input: AttachClient): Promise<void>;
|
|
||||||
/** Removes a browser attachment; it does not terminate the process or native session. */
|
|
||||||
detach(clientId: string): Promise<void>;
|
|
||||||
prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt>;
|
|
||||||
setModel(selection: HarnessSelection): Promise<HarnessSelection>;
|
|
||||||
abort(turnId: string): Promise<void>;
|
|
||||||
respondInteraction(input: HarnessInteractionResponse): Promise<void>;
|
|
||||||
events(listener: (event: HarnessEvent) => void): () => void;
|
|
||||||
/** Stops the active process while retaining the resumable native session. */
|
|
||||||
evictProcess(reason: HarnessCloseReason): Promise<void>;
|
|
||||||
/** Explicitly and destructively ends the native session. */
|
|
||||||
endSession(reason: HarnessCloseReason): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessConversationService {
|
|
||||||
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot>;
|
|
||||||
/** Removes only the browser attachment represented by the input. */
|
|
||||||
detach(input: DetachConversation): Promise<void>;
|
|
||||||
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt>;
|
|
||||||
/** Replays persisted Gateway journal rows after the supplied monotonic sequence. */
|
|
||||||
subscribeFrom(conversationId: string, afterSequence: number): AsyncIterable<HarnessEventEnvelope>;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { HarnessSelection } from './contracts.js';
|
|
||||||
|
|
||||||
export const HARNESS_ERROR_CODES = [
|
|
||||||
'auth_required',
|
|
||||||
'selection_invalid',
|
|
||||||
'catalog_unavailable',
|
|
||||||
'catalog_stale',
|
|
||||||
'model_unavailable',
|
|
||||||
'no_viable_provider',
|
|
||||||
'session_create_failed',
|
|
||||||
'session_not_found',
|
|
||||||
'resume_conflict',
|
|
||||||
'session_busy',
|
|
||||||
'auth_bundle_concurrency_unverified',
|
|
||||||
'adapter_unavailable',
|
|
||||||
'sandbox_unavailable',
|
|
||||||
'rpc_version_unsupported',
|
|
||||||
'rpc_protocol_error',
|
|
||||||
'process_exited',
|
|
||||||
'outcome_unknown',
|
|
||||||
'interaction_unsupported',
|
|
||||||
'aborted',
|
|
||||||
] as const satisfies readonly string[];
|
|
||||||
|
|
||||||
export type HarnessErrorCode = (typeof HARNESS_ERROR_CODES)[number];
|
|
||||||
|
|
||||||
export interface HarnessErrorDto<Code extends HarnessErrorCode = HarnessErrorCode> {
|
|
||||||
readonly code: Code;
|
|
||||||
/** Safe for browser and operator-facing surfaces. */
|
|
||||||
readonly message: string;
|
|
||||||
readonly retryable: boolean;
|
|
||||||
readonly correlationId: string;
|
|
||||||
/** The requested selection; errors never report a substituted effective selection. */
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Closed discriminated union over every stable harness error code. */
|
|
||||||
export type HarnessError = {
|
|
||||||
readonly [Code in HarnessErrorCode]: HarnessErrorDto<Code>;
|
|
||||||
}[HarnessErrorCode];
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import type { HarnessError } from './errors.js';
|
|
||||||
import type { HarnessSelection, HarnessSessionState } from './contracts.js';
|
|
||||||
|
|
||||||
export const HARNESS_CAPABILITIES = [
|
|
||||||
'modelSelection',
|
|
||||||
'thinkingLevels',
|
|
||||||
'images',
|
|
||||||
'toolEvents',
|
|
||||||
'extensionUi',
|
|
||||||
'steering',
|
|
||||||
'followUp',
|
|
||||||
'compaction',
|
|
||||||
'persistentResume',
|
|
||||||
] as const satisfies readonly string[];
|
|
||||||
|
|
||||||
export type HarnessCapability = (typeof HARNESS_CAPABILITIES)[number];
|
|
||||||
|
|
||||||
/** Durable lifecycle states; later persistence enforces one terminal response per request. */
|
|
||||||
export type HarnessInteractionState = 'pending' | 'responded' | 'cancelled' | 'expired';
|
|
||||||
export type HarnessInteractionType = 'confirm' | 'select' | 'input' | 'editor';
|
|
||||||
|
|
||||||
export interface HarnessUsage {
|
|
||||||
readonly inputTokens: number;
|
|
||||||
readonly outputTokens: number;
|
|
||||||
readonly totalTokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessTurnOutcome =
|
|
||||||
| 'settled'
|
|
||||||
| 'failed'
|
|
||||||
| 'aborted'
|
|
||||||
| 'interrupted'
|
|
||||||
| 'outcome_unknown';
|
|
||||||
|
|
||||||
export interface HarnessSessionStartedEvent {
|
|
||||||
readonly type: 'session.started';
|
|
||||||
readonly state: HarnessSessionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessSessionStateEvent {
|
|
||||||
readonly type: 'session.state';
|
|
||||||
readonly state: HarnessSessionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessSessionIdentityChangedEvent {
|
|
||||||
readonly type: 'session.identity_changed';
|
|
||||||
readonly identityGeneration: number;
|
|
||||||
readonly label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessTurnStartedEvent {
|
|
||||||
readonly type: 'turn.started';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessTextDeltaEvent {
|
|
||||||
readonly type: 'text.delta';
|
|
||||||
readonly text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessThinkingDeltaEvent {
|
|
||||||
readonly type: 'thinking.delta';
|
|
||||||
readonly text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessToolStartedEvent {
|
|
||||||
readonly type: 'tool.started';
|
|
||||||
readonly toolCallId: string;
|
|
||||||
readonly toolName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessToolUpdatedEvent {
|
|
||||||
readonly type: 'tool.updated';
|
|
||||||
readonly toolCallId: string;
|
|
||||||
readonly toolName: string;
|
|
||||||
readonly message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessToolFinishedEvent {
|
|
||||||
readonly type: 'tool.finished';
|
|
||||||
readonly toolCallId: string;
|
|
||||||
readonly toolName: string;
|
|
||||||
readonly isError: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessInteractionRequiredEvent {
|
|
||||||
readonly type: 'interaction.required';
|
|
||||||
readonly requestId: string;
|
|
||||||
readonly interactionType: HarnessInteractionType;
|
|
||||||
readonly state: HarnessInteractionState;
|
|
||||||
readonly prompt: string;
|
|
||||||
readonly options?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessUsageUpdatedEvent {
|
|
||||||
readonly type: 'usage.updated';
|
|
||||||
readonly usage: HarnessUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessTurnCompletedEvent {
|
|
||||||
readonly type: 'turn.completed';
|
|
||||||
readonly outcome: HarnessTurnOutcome;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarnessErrorEvent {
|
|
||||||
readonly type: 'error';
|
|
||||||
readonly error: HarnessError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HarnessEvent =
|
|
||||||
| HarnessSessionStartedEvent
|
|
||||||
| HarnessSessionStateEvent
|
|
||||||
| HarnessSessionIdentityChangedEvent
|
|
||||||
| HarnessTurnStartedEvent
|
|
||||||
| HarnessTextDeltaEvent
|
|
||||||
| HarnessThinkingDeltaEvent
|
|
||||||
| HarnessToolStartedEvent
|
|
||||||
| HarnessToolUpdatedEvent
|
|
||||||
| HarnessToolFinishedEvent
|
|
||||||
| HarnessInteractionRequiredEvent
|
|
||||||
| HarnessUsageUpdatedEvent
|
|
||||||
| HarnessTurnCompletedEvent
|
|
||||||
| HarnessErrorEvent;
|
|
||||||
|
|
||||||
/** Persisted normalized event plus Gateway-owned ordering and native reconciliation metadata. */
|
|
||||||
export interface HarnessEventEnvelope {
|
|
||||||
readonly conversationId: string;
|
|
||||||
readonly nativeSessionId: string;
|
|
||||||
readonly turnId?: string;
|
|
||||||
readonly correlationId: string;
|
|
||||||
/** Monotonic Gateway journal sequence within the conversation. */
|
|
||||||
readonly sequence: number;
|
|
||||||
/** Native session-entry cursor when the harness provides one. */
|
|
||||||
readonly nativeEntryCursor?: string;
|
|
||||||
readonly occurredAt: string;
|
|
||||||
readonly harnessId: string;
|
|
||||||
/** Exact effective selected provider/model tuple; no alternate success selection is exposed. */
|
|
||||||
readonly selection: HarnessSelection;
|
|
||||||
readonly event: HarnessEvent;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './contracts.js';
|
|
||||||
export * from './events.js';
|
|
||||||
export * from './errors.js';
|
|
||||||
@@ -8,4 +8,3 @@ export * from './routing/index.js';
|
|||||||
export * from './commands/index.js';
|
export * from './commands/index.js';
|
||||||
export * from './federation/index.js';
|
export * from './federation/index.js';
|
||||||
export * from './reflection/index.js';
|
export * from './reflection/index.js';
|
||||||
export * from './harness/index.js';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user