Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d752db93e4 | ||
|
|
ef66b0e7d6 | ||
|
|
ebe415132e | ||
|
|
f16f206a0a | ||
|
|
a186922e3a | ||
|
|
43513c28f7 | ||
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
ddf8616716 |
@@ -21,6 +21,7 @@ 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';
|
||||||
@@ -60,6 +61,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
CommandsModule,
|
CommandsModule,
|
||||||
GCModule,
|
GCModule,
|
||||||
|
HarnessModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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.
@@ -0,0 +1,90 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistrationError,
|
||||||
|
HarnessRegistry,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
describe('HarnessRegistry', () => {
|
||||||
|
it('registers and looks up an adapter by harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
|
||||||
|
registry.register(adapter);
|
||||||
|
|
||||||
|
expect(registry.get('fake')).toBe(adapter);
|
||||||
|
expect(registry.has('fake')).toBe(true);
|
||||||
|
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a blank adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||||
|
expect(registry.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate adapter id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||||
|
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||||
|
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||||
|
// The original registration is untouched.
|
||||||
|
expect(registry.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
registry.get('missing');
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||||
|
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||||
|
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||||
|
expect(registry.has('missing')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessErrorCode,
|
||||||
|
HarnessErrorDto,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||||
|
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||||
|
* tuple — there is no field through which a substituted "effective" selection
|
||||||
|
* could ever be reported.
|
||||||
|
*/
|
||||||
|
export class HarnessOperationError extends Error {
|
||||||
|
readonly code: HarnessErrorCode;
|
||||||
|
readonly dto: HarnessErrorDto;
|
||||||
|
|
||||||
|
constructor(dto: HarnessErrorDto) {
|
||||||
|
super(dto.message);
|
||||||
|
this.name = 'HarnessOperationError';
|
||||||
|
this.code = dto.code;
|
||||||
|
this.dto = dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||||
|
export function operationError(
|
||||||
|
code: HarnessErrorCode,
|
||||||
|
message: string,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
correlationId: string,
|
||||||
|
retryable = false,
|
||||||
|
): HarnessOperationError {
|
||||||
|
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||||
|
export class HarnessAdapterUnavailableError extends Error {
|
||||||
|
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||||
|
|
||||||
|
constructor(readonly harnessId: string) {
|
||||||
|
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||||
|
this.name = 'HarnessAdapterUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||||
|
|
||||||
|
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||||
|
export class HarnessRegistrationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly reason: HarnessRegistrationFailure,
|
||||||
|
readonly harnessId: string,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
reason === 'blank_id'
|
||||||
|
? 'A harness adapter id must be a non-empty string.'
|
||||||
|
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||||
|
);
|
||||||
|
this.name = 'HarnessRegistrationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||||
|
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||||
|
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessRegistry {
|
||||||
|
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||||
|
|
||||||
|
register(adapter: HarnessAdapter): void {
|
||||||
|
const id = adapter.id;
|
||||||
|
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||||
|
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||||
|
}
|
||||||
|
if (this.adapters.has(id)) {
|
||||||
|
throw new HarnessRegistrationError('duplicate_id', id);
|
||||||
|
}
|
||||||
|
this.adapters.set(id, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(harnessId: string): HarnessAdapter {
|
||||||
|
const adapter = this.adapters.get(harnessId);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new HarnessAdapterUnavailableError(harnessId);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(harnessId: string): boolean {
|
||||||
|
return this.adapters.has(harnessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): readonly HarnessAdapter[] {
|
||||||
|
return [...this.adapters.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
||||||
|
import {
|
||||||
|
HarnessScopeViolationError,
|
||||||
|
HarnessService,
|
||||||
|
type TrustedGatewayScope,
|
||||||
|
} from './harness.service.js';
|
||||||
|
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||||
|
|
||||||
|
const SCOPE: TrustedGatewayScope = {
|
||||||
|
actorId: 'actor-trusted',
|
||||||
|
tenantId: 'tenant-trusted',
|
||||||
|
seatId: 'seat-trusted',
|
||||||
|
correlationId: 'correlation-trusted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const READ_CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: SCOPE.actorId,
|
||||||
|
tenantId: SCOPE.tenantId,
|
||||||
|
seatId: SCOPE.seatId,
|
||||||
|
correlationId: SCOPE.correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(capabilities?: readonly HarnessCapability[]) {
|
||||||
|
const registry = new HarnessRegistry();
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
||||||
|
registry.register(adapter);
|
||||||
|
const service = new HarnessService(registry);
|
||||||
|
return { registry, adapter, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const entry = catalog.models.find((model) => model.availability === 'available');
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('fixture requires an available model');
|
||||||
|
}
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HarnessService', () => {
|
||||||
|
it('derives the actor context from trusted scope on create', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const snapshot = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
||||||
|
expect(snapshot.state).toBe('idle');
|
||||||
|
expect(snapshot.selection).toEqual(selection);
|
||||||
|
expect(snapshot.nativeSessionId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects server-authority fields supplied by an external caller', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const hostile = {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection,
|
||||||
|
seatId: 'attacker-seat',
|
||||||
|
executablePath: '/usr/bin/evil',
|
||||||
|
home: '/home/attacker',
|
||||||
|
cwd: '/tmp/attacker',
|
||||||
|
nativeSessionPath: '/var/native/attacker.jsonl',
|
||||||
|
} as unknown as Parameters<HarnessService['createSession']>[1];
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, hostile);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
||||||
|
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'ghost-harness',
|
||||||
|
providerId: 'p',
|
||||||
|
modelId: 'm',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('adapter_unavailable');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
const selection: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
// No substitution: the DTO tuple is exactly what was requested.
|
||||||
|
expect(dto.selection).toEqual(selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
||||||
|
const { service, adapter } = setup();
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
|
||||||
|
const created = await service.createSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
expect(created.state).toBe('idle');
|
||||||
|
expect(created.processId).toBeTruthy();
|
||||||
|
expect(created.attachedClientIds).toEqual([]);
|
||||||
|
|
||||||
|
const resumed = await service.resumeSession(SCOPE, {
|
||||||
|
conversationId: 'conversation-resume',
|
||||||
|
nativeSessionId: 'native-preexisting-123',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
// Resume binds the supplied native session; create mints a fresh one.
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
||||||
|
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
await service.attach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
||||||
|
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
||||||
|
|
||||||
|
const afterDetach = await service.detach(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
clientId: 'browser-1',
|
||||||
|
});
|
||||||
|
// Detach removes the browser attachment only; the process stays alive.
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).toBe('idle');
|
||||||
|
expect(afterDetach.processId).toBeTruthy();
|
||||||
|
|
||||||
|
const afterEvict = await service.evict(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'idle_timeout',
|
||||||
|
});
|
||||||
|
// Evict stops the process but retains the resumable native session.
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
||||||
|
|
||||||
|
const afterEnd = await service.end(SCOPE, {
|
||||||
|
conversationId: 'conversation-create',
|
||||||
|
reason: 'session_ended',
|
||||||
|
});
|
||||||
|
// End destructively terminates the native session.
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails typed when an unsupported capability is exercised', async () => {
|
||||||
|
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
||||||
|
(capability) => capability !== 'extensionUi',
|
||||||
|
);
|
||||||
|
const { service, adapter } = setup(withoutExtensionUi);
|
||||||
|
const selection = await availableSelection(adapter);
|
||||||
|
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await service.respondInteraction(SCOPE, {
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import {
|
||||||
|
HarnessAdapterUnavailableError,
|
||||||
|
HarnessRegistry,
|
||||||
|
operationError,
|
||||||
|
} from './harness.registry.js';
|
||||||
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted, server-derived authority. In production this is produced by the
|
||||||
|
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||||
|
*/
|
||||||
|
export interface TrustedGatewayScope {
|
||||||
|
readonly actorId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly seatId: string;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||||
|
const FORBIDDEN_REQUEST_FIELDS = [
|
||||||
|
'actorId',
|
||||||
|
'tenantId',
|
||||||
|
'correlationId',
|
||||||
|
'seatId',
|
||||||
|
'seat',
|
||||||
|
'executable',
|
||||||
|
'executablePath',
|
||||||
|
'home',
|
||||||
|
'homeDir',
|
||||||
|
'cwd',
|
||||||
|
'workingDir',
|
||||||
|
'workingDirectory',
|
||||||
|
'nativeSessionPath',
|
||||||
|
'sessionPath',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||||
|
export class HarnessScopeViolationError extends Error {
|
||||||
|
constructor(readonly field: string) {
|
||||||
|
super(`External request supplied server-authority field "${field}".`);
|
||||||
|
this.name = 'HarnessScopeViolationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeHarnessSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly nativeSessionId: string;
|
||||||
|
readonly selection: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetachClientRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly clientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvictSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndSessionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly reason: HarnessCloseReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RespondInteractionRequest {
|
||||||
|
readonly conversationId: string;
|
||||||
|
readonly response: HarnessInteractionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSession {
|
||||||
|
readonly harnessId: string;
|
||||||
|
readonly handle: HarnessSessionHandle;
|
||||||
|
readonly correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||||
|
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||||
|
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||||
|
* lifecycle operations.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HarnessService {
|
||||||
|
private readonly sessions = new Map<string, ActiveSession>();
|
||||||
|
|
||||||
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: CreateHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.create({ context, conversationId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: ResumeHarnessSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const { conversationId, nativeSessionId, selection } = request;
|
||||||
|
const adapter = this.resolveAdapter(scope, selection);
|
||||||
|
const context = deriveActorContext(scope);
|
||||||
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||||
|
|
||||||
|
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||||
|
this.sessions.set(conversationId, {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
handle,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: AttachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.attach({ clientId: request.clientId });
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: DetachClientRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.detach(request.clientId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async evict(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EvictSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.evictProcess(request.reason);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: EndSessionRequest,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.endSession(request.reason);
|
||||||
|
const snapshot = await handle.snapshot();
|
||||||
|
this.sessions.delete(request.conversationId);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
request: RespondInteractionRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTrustedRequest(request);
|
||||||
|
const handle = this.requireHandle(scope, request.conversationId);
|
||||||
|
await handle.respondInteraction(request.response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<HarnessSessionSnapshot> {
|
||||||
|
const handle = this.requireHandle(scope, conversationId);
|
||||||
|
return handle.snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||||
|
try {
|
||||||
|
return this.registry.get(selection.harnessId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HarnessAdapterUnavailableError) {
|
||||||
|
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSelectionAvailable(
|
||||||
|
scope: TrustedGatewayScope,
|
||||||
|
catalogPromise: Promise<HarnessCatalog>,
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<void> {
|
||||||
|
const catalog = await catalogPromise;
|
||||||
|
const entry = catalog.models.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.harnessId === selection.harnessId &&
|
||||||
|
candidate.providerId === selection.providerId &&
|
||||||
|
candidate.modelId === selection.modelId,
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
// No first-row fallback: reject the requested tuple unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
scope.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||||
|
const active = this.sessions.get(conversationId);
|
||||||
|
if (!active) {
|
||||||
|
throw operationError(
|
||||||
|
'session_not_found',
|
||||||
|
`No active harness session for conversation "${conversationId}".`,
|
||||||
|
{ harnessId: '', providerId: '', modelId: '' },
|
||||||
|
scope.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return active.handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||||
|
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||||
|
return {
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
seatId: scope.seatId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject any request object that carries a server-authority field. */
|
||||||
|
function assertTrustedRequest(request: object): void {
|
||||||
|
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||||
|
throw new HarnessScopeViolationError(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export the typed operation error so callers importing from the service
|
||||||
|
// have the discriminated failure type without reaching into the registry.
|
||||||
|
export { HarnessOperationError } from './harness.registry.js';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||||
|
*
|
||||||
|
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
|
||||||
|
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
|
||||||
|
*/
|
||||||
|
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
|
||||||
|
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
|
||||||
|
|
||||||
|
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
|
||||||
|
export type HarnessServiceToken = typeof HARNESS_SERVICE;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||||
|
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'actor-1',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
seatId: 'seat-1',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||||
|
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||||
|
|
||||||
|
describe('FakeHarnessAdapter no-substitution', () => {
|
||||||
|
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const available = catalog.models.find(
|
||||||
|
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||||
|
);
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
const selected: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
selection: selected,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: 'fake',
|
||||||
|
providerId: 'ghost-provider',
|
||||||
|
modelId: 'ghost-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(bogus);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
// The DTO echoes the exact requested tuple, unchanged.
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
// No substitution to the first catalog row.
|
||||||
|
expect(dto.selection).not.toEqual({
|
||||||
|
harnessId: firstRow.harnessId,
|
||||||
|
providerId: firstRow.providerId,
|
||||||
|
modelId: firstRow.modelId,
|
||||||
|
});
|
||||||
|
// The active selection is untouched by the rejected request.
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||||
|
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||||
|
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||||
|
expect(unavailable).toBeDefined();
|
||||||
|
expect(available).toBeDefined();
|
||||||
|
|
||||||
|
const startingSelection: HarnessSelection = {
|
||||||
|
harnessId: available!.harnessId,
|
||||||
|
providerId: available!.providerId,
|
||||||
|
modelId: available!.modelId,
|
||||||
|
};
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conversation-2',
|
||||||
|
selection: startingSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: unavailable!.harnessId,
|
||||||
|
providerId: unavailable!.providerId,
|
||||||
|
modelId: unavailable!.modelId,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
await handle.setModel(requested);
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('model_unavailable');
|
||||||
|
expect(dto.selection).toEqual(requested);
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import type {
|
||||||
|
AttachClient,
|
||||||
|
CreateHarnessSession,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessCapability,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessCloseReason,
|
||||||
|
HarnessDescriptor,
|
||||||
|
HarnessEvent,
|
||||||
|
HarnessInteractionResponse,
|
||||||
|
HarnessPrompt,
|
||||||
|
HarnessPromptReceipt,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSessionHandle,
|
||||||
|
HarnessSessionSnapshot,
|
||||||
|
HarnessSessionState,
|
||||||
|
ResumeHarnessSession,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||||
|
import { operationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
export interface FakeHarnessAdapterOptions {
|
||||||
|
readonly id: string;
|
||||||
|
readonly capabilities?: readonly HarnessCapability[];
|
||||||
|
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAKE_PROVIDER = 'fake-openai';
|
||||||
|
|
||||||
|
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-mini',
|
||||||
|
displayName: 'Fake Mini',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-pro',
|
||||||
|
displayName: 'Fake Pro',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text', 'image'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId,
|
||||||
|
providerId: FAKE_PROVIDER,
|
||||||
|
modelId: 'fake-legacy',
|
||||||
|
displayName: 'Fake Legacy',
|
||||||
|
reasoningCapability: false,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'unavailable',
|
||||||
|
availability: 'unavailable',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||||
|
return (
|
||||||
|
entry.harnessId === selection.harnessId &&
|
||||||
|
entry.providerId === selection.providerId &&
|
||||||
|
entry.modelId === selection.modelId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory harness session handle used by the fake adapter and by the shared
|
||||||
|
* conformance suite. It enforces the two invariants the real adapters must also
|
||||||
|
* honor: model selection is validated against the catalog and is NEVER
|
||||||
|
* substituted, and unsupported capabilities fail with a typed error.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||||
|
private state: HarnessSessionState = 'idle';
|
||||||
|
private processId: string | undefined;
|
||||||
|
private readonly attachedClientIds = new Set<string>();
|
||||||
|
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly conversationId: string,
|
||||||
|
private readonly nativeSessionId: string,
|
||||||
|
private readonly seatId: string,
|
||||||
|
private selection: HarnessSelection,
|
||||||
|
private readonly correlationId: string,
|
||||||
|
private readonly capabilities: readonly HarnessCapability[],
|
||||||
|
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||||
|
) {
|
||||||
|
this.processId = `process-${nativeSessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
nativeSessionId: this.nativeSessionId,
|
||||||
|
processId: this.processId,
|
||||||
|
seatId: this.seatId,
|
||||||
|
selection: this.selection,
|
||||||
|
state: this.state,
|
||||||
|
attachedClientIds: [...this.attachedClientIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(input: AttachClient): Promise<void> {
|
||||||
|
this.attachedClientIds.add(input.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(clientId: string): Promise<void> {
|
||||||
|
// Removes the browser attachment only; the process and native session persist.
|
||||||
|
this.attachedClientIds.delete(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||||
|
return {
|
||||||
|
conversationId: this.conversationId,
|
||||||
|
turnId: input.turnId,
|
||||||
|
correlationId: input.correlationId,
|
||||||
|
state: 'accepted',
|
||||||
|
selection: this.selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||||
|
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||||
|
if (!entry) {
|
||||||
|
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||||
|
throw operationError(
|
||||||
|
'selection_invalid',
|
||||||
|
'The requested harness/provider/model tuple is not in the catalog.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.availability === 'unavailable') {
|
||||||
|
throw operationError(
|
||||||
|
'model_unavailable',
|
||||||
|
'The requested model is currently unavailable.',
|
||||||
|
selection,
|
||||||
|
this.correlationId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.selection = selection;
|
||||||
|
return this.selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(_turnId: string): Promise<void> {
|
||||||
|
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||||
|
if (!this.capabilities.includes('extensionUi')) {
|
||||||
|
throw operationError(
|
||||||
|
'interaction_unsupported',
|
||||||
|
'This harness does not support interactive responses.',
|
||||||
|
this.selection,
|
||||||
|
this.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events(listener: (event: HarnessEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Stop the process but keep the resumable native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'evicted';
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||||
|
// Destructively end the native session.
|
||||||
|
this.processId = undefined;
|
||||||
|
this.state = 'ended';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||||
|
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||||
|
* two paths are observably distinct.
|
||||||
|
*/
|
||||||
|
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||||
|
readonly id: string;
|
||||||
|
private readonly capabilities: readonly HarnessCapability[];
|
||||||
|
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||||
|
private createdCount = 0;
|
||||||
|
|
||||||
|
constructor(options: FakeHarnessAdapterOptions) {
|
||||||
|
this.id = options.id;
|
||||||
|
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||||
|
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
displayName: `Fake harness (${this.id})`,
|
||||||
|
capabilities: this.capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||||
|
return {
|
||||||
|
harnessId: this.id,
|
||||||
|
version: '1.0.0',
|
||||||
|
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||||
|
models: this.catalogEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
this.createdCount += 1;
|
||||||
|
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||||
|
return new FakeHarnessSessionHandle(
|
||||||
|
input.conversationId,
|
||||||
|
input.nativeSessionId,
|
||||||
|
input.context.seatId,
|
||||||
|
input.selection,
|
||||||
|
input.context.correlationId,
|
||||||
|
this.capabilities,
|
||||||
|
this.catalogEntries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
HarnessActorContext,
|
||||||
|
HarnessAdapter,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { HarnessOperationError } from '../harness.registry.js';
|
||||||
|
|
||||||
|
const CONTEXT: HarnessActorContext = {
|
||||||
|
actorId: 'contract-actor',
|
||||||
|
tenantId: 'contract-tenant',
|
||||||
|
seatId: 'contract-seat',
|
||||||
|
correlationId: 'contract-correlation',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
||||||
|
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
||||||
|
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('contract fixture requires at least one catalog model');
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
return undefined;
|
||||||
|
} catch (caught) {
|
||||||
|
return caught;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
||||||
|
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
||||||
|
* the native Pi adapter so both share one behavioral contract.
|
||||||
|
*/
|
||||||
|
export function runHarnessAdapterContract(
|
||||||
|
label: string,
|
||||||
|
createAdapter: () => HarnessAdapter,
|
||||||
|
): void {
|
||||||
|
describe(`harness adapter contract: ${label}`, () => {
|
||||||
|
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
|
||||||
|
const created = await (
|
||||||
|
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
||||||
|
).snapshot();
|
||||||
|
const resumed = await (
|
||||||
|
await adapter.resume({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-resume',
|
||||||
|
nativeSessionId: 'native-supplied-1',
|
||||||
|
selection,
|
||||||
|
})
|
||||||
|
).snapshot();
|
||||||
|
|
||||||
|
expect(created.nativeSessionId).toBeTruthy();
|
||||||
|
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
||||||
|
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
||||||
|
expect(created.seatId).toBe(CONTEXT.seatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-lifecycle',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.attach({ clientId: 'browser-1' });
|
||||||
|
await handle.detach('browser-1');
|
||||||
|
const afterDetach = await handle.snapshot();
|
||||||
|
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||||
|
expect(afterDetach.state).not.toBe('evicted');
|
||||||
|
expect(afterDetach.state).not.toBe('ended');
|
||||||
|
|
||||||
|
await handle.evictProcess('idle_timeout');
|
||||||
|
const afterEvict = await handle.snapshot();
|
||||||
|
expect(afterEvict.state).toBe('evicted');
|
||||||
|
// The native session survives eviction (resumable); the process does not.
|
||||||
|
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
||||||
|
expect(afterEvict.processId).toBeUndefined();
|
||||||
|
|
||||||
|
await handle.endSession('session_ended');
|
||||||
|
const afterEnd = await handle.snapshot();
|
||||||
|
expect(afterEnd.state).toBe('ended');
|
||||||
|
// End is not an alias of evict.
|
||||||
|
expect(afterEnd.state).not.toBe(afterEvict.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never substitutes the first catalog row for an unknown selection', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const firstRow = catalog.models[0];
|
||||||
|
if (!firstRow) {
|
||||||
|
throw new Error('contract fixture requires a catalog model');
|
||||||
|
}
|
||||||
|
const start = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-nosub',
|
||||||
|
selection: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bogus: HarnessSelection = {
|
||||||
|
harnessId: adapter.id,
|
||||||
|
providerId: 'contract-ghost-provider',
|
||||||
|
modelId: 'contract-ghost-model',
|
||||||
|
};
|
||||||
|
const error = await captureError(() => handle.setModel(bogus));
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
const dto = (error as HarnessOperationError).dto;
|
||||||
|
expect(dto.code).toBe('selection_invalid');
|
||||||
|
expect(dto.selection).toEqual(bogus);
|
||||||
|
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
||||||
|
expect((await handle.snapshot()).selection).toEqual(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
||||||
|
const adapter = createAdapter();
|
||||||
|
const descriptor = await adapter.describe(CONTEXT);
|
||||||
|
const catalog = await adapter.catalog(CONTEXT);
|
||||||
|
const selection = toSelection(pickAvailable(catalog.models));
|
||||||
|
const handle = await adapter.create({
|
||||||
|
context: CONTEXT,
|
||||||
|
conversationId: 'conv-interaction',
|
||||||
|
selection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
requestId: 'interaction-1',
|
||||||
|
type: 'confirm',
|
||||||
|
accepted: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (descriptor.capabilities.includes('extensionUi')) {
|
||||||
|
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const error = await captureError(() => handle.respondInteraction(response));
|
||||||
|
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||||
|
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
// The exact harness/provider/model tuple and its closed enum companions are the
|
||||||
|
// shared domain types — re-exported here so web consumers (and the runtime
|
||||||
|
// guards) import one shape, never a divergent local redefinition.
|
||||||
|
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
|
||||||
|
|
||||||
|
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
|
||||||
|
* harness id is kept distinct from any provider id — they are never merged. */
|
||||||
|
export interface HarnessSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One selectable model in a harness catalog. Extends the `{harnessId,
|
||||||
|
* providerId, modelId}` tuple with the display/availability metadata the UI
|
||||||
|
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
|
||||||
|
* because it arrives from untrusted JSON and is only ever displayed. */
|
||||||
|
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||||
|
displayName: string;
|
||||||
|
reasoningCapability: boolean;
|
||||||
|
inputTypes: string[];
|
||||||
|
authState: HarnessAuthState;
|
||||||
|
availability: HarnessModelAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
|
||||||
|
export interface HarnessCatalog {
|
||||||
|
harnessId: string;
|
||||||
|
version: string;
|
||||||
|
fingerprint: string;
|
||||||
|
models: HarnessCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Conversation returned by the gateway API. */
|
/** Conversation returned by the gateway API. */
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
} from './chat-api';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubFetch(): ReturnType<typeof vi.fn> {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every URL the client actually requested, across all calls. */
|
||||||
|
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => String(call[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat-api', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const harnesses = await fetchHarnesses();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
||||||
|
expect(harnesses).toEqual([
|
||||||
|
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||||
|
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'abc123',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('pi');
|
||||||
|
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) throw new Error('expected ok catalog');
|
||||||
|
expect(result.catalog.harnessId).toBe('pi');
|
||||||
|
expect(result.catalog.models).toHaveLength(1);
|
||||||
|
expect(result.catalog.models[0]).toMatchObject({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
availability: 'available',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'adapter_unavailable',
|
||||||
|
message: 'raw gateway detail that must not leak verbatim',
|
||||||
|
harnessId: 'attacker-echo',
|
||||||
|
extra: { hostile: 'blob' },
|
||||||
|
},
|
||||||
|
404,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchCatalog('ghost');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected unavailable result');
|
||||||
|
expect(result.code).toBe('catalog_unavailable');
|
||||||
|
// harnessId comes from the request, never the (untrusted) response body.
|
||||||
|
expect(result.harnessId).toBe('ghost');
|
||||||
|
expect(typeof result.message).toBe('string');
|
||||||
|
// The raw response body is never rendered/returned verbatim.
|
||||||
|
expect(JSON.stringify(result)).not.toContain('hostile');
|
||||||
|
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
||||||
|
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await persistSelection({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
||||||
|
const init = call?.[1] as RequestInit;
|
||||||
|
expect(String(init.method).toUpperCase()).toBe('PUT');
|
||||||
|
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
||||||
|
expect(JSON.parse(String(init.body))).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'raw detail that must not leak',
|
||||||
|
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error('expected failed persist');
|
||||||
|
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
||||||
|
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
||||||
|
expect(result.requested).toEqual(requested);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('raw detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
fetchMock.mockResolvedValue(json([]));
|
||||||
|
await fetchHarnesses();
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
||||||
|
);
|
||||||
|
await fetchCatalog('pi');
|
||||||
|
fetchMock.mockResolvedValue(json({ selection: null }));
|
||||||
|
await fetchPersistedSelection();
|
||||||
|
|
||||||
|
for (const url of requestedUrls(fetchMock)) {
|
||||||
|
expect(url).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
||||||
|
* UI depends on. Every response body is untrusted and is normalized through the
|
||||||
|
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
||||||
|
* are mapped to typed, body-free error results so a raw gateway body is never
|
||||||
|
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
||||||
|
*
|
||||||
|
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
||||||
|
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
||||||
|
*/
|
||||||
|
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
||||||
|
* never a thrown raw body. */
|
||||||
|
export type CatalogResult =
|
||||||
|
| { ok: true; catalog: HarnessCatalog }
|
||||||
|
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
||||||
|
|
||||||
|
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
||||||
|
|
||||||
|
/** A persist either confirms the stored tuple or reports a typed domain failure
|
||||||
|
* that echoes back the exact tuple the caller requested. */
|
||||||
|
export type SelectionPersistResult =
|
||||||
|
| { ok: true; selection: HarnessSelection }
|
||||||
|
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
||||||
|
|
||||||
|
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
||||||
|
* never surfaced. */
|
||||||
|
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
||||||
|
|
||||||
|
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
||||||
|
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
||||||
|
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
||||||
|
* read from the body. */
|
||||||
|
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<unknown> {
|
||||||
|
return response.json().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
||||||
|
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||||
|
const code = (body as { code: unknown }).code;
|
||||||
|
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
||||||
|
}
|
||||||
|
// Default to the more conservative "invalid" classification for anything
|
||||||
|
// unrecognized rather than guessing "model_unavailable".
|
||||||
|
return 'selection_invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
||||||
|
* normalizes to an empty list (the UI then has no harness to select). */
|
||||||
|
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
||||||
|
const response = await fetch('/api/harnesses', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return asHarnessSummaries(await readJson(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
||||||
|
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
||||||
|
* fallback catalog or a rendered raw body. */
|
||||||
|
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
||||||
|
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'catalog_unavailable',
|
||||||
|
// Scoped to the requested harness id, never the untrusted body's echo.
|
||||||
|
harnessId,
|
||||||
|
message: CATALOG_UNAVAILABLE_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
||||||
|
* unset or malformed. */
|
||||||
|
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const body = await readJson(response);
|
||||||
|
if (typeof body !== 'object' || body === null) return null;
|
||||||
|
return asHarnessSelection((body as { selection?: unknown }).selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
||||||
|
* On success returns the confirmed selection; on a typed domain failure (422)
|
||||||
|
* or validation error, returns a typed result carrying the EXACT requested
|
||||||
|
* tuple — never the body's echo — and never the raw body text. */
|
||||||
|
export async function persistSelection(
|
||||||
|
selection: HarnessSelection,
|
||||||
|
): Promise<SelectionPersistResult> {
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId: selection.harnessId,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
modelId: selection.modelId,
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/chat/preferences/selection', {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requested),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await readJson(response);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: safeSelectionCode(body),
|
||||||
|
// Fixed copy only — the untrusted body's message is never surfaced.
|
||||||
|
message: SELECTION_REJECTED_MESSAGE,
|
||||||
|
requested,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = await readJson(response);
|
||||||
|
const confirmed =
|
||||||
|
typeof body === 'object' && body !== null
|
||||||
|
? asHarnessSelection((body as { selection?: unknown }).selection)
|
||||||
|
: null;
|
||||||
|
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
||||||
|
// the server accepted the tuple, so the requested tuple is the source of truth.
|
||||||
|
return { ok: true, selection: confirmed ?? requested };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
@@ -9,6 +10,23 @@ interface ComposerProps {
|
|||||||
* pre-ack window where a second send could otherwise slip through. */
|
* pre-ack window where a second send could otherwise slip through. */
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
hasConversation: boolean;
|
hasConversation: boolean;
|
||||||
|
/** Structured harness/provider/model selection state. The composer never
|
||||||
|
* accepts free-text provider/model — every sendable tuple is a validated,
|
||||||
|
* persisted catalog entry, and the send projection is derived from it. */
|
||||||
|
harness: HarnessSelectionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct provider ids present in the current catalog, in first-seen
|
||||||
|
* order — the provider select is catalog-derived, never a hardcoded list. */
|
||||||
|
function providerOptions(harness: HarnessSelectionValue): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const model of harness.catalog?.models ?? []) {
|
||||||
|
if (seen.has(model.providerId)) continue;
|
||||||
|
seen.add(model.providerId);
|
||||||
|
out.push(model.providerId);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
@@ -17,21 +35,19 @@ export function Composer({
|
|||||||
streaming,
|
streaming,
|
||||||
sending,
|
sending,
|
||||||
hasConversation,
|
hasConversation,
|
||||||
|
harness,
|
||||||
}: ComposerProps): ReactElement {
|
}: ComposerProps): ReactElement {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [provider, setProvider] = useState('');
|
|
||||||
const [modelId, setModelId] = useState('');
|
|
||||||
const busy = streaming || sending;
|
const busy = streaming || sending;
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||||
|
// selection can never emit, so provider/model never travel as free text.
|
||||||
|
if (!harness.canSend) return;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
onSend({
|
onSend({ content: trimmed, ...harness.projection });
|
||||||
content: trimmed,
|
|
||||||
provider: provider.trim() || undefined,
|
|
||||||
modelId: modelId.trim() || undefined,
|
|
||||||
});
|
|
||||||
setContent('');
|
setContent('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +58,19 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scope the model options to the intentionally selected provider. With no
|
||||||
|
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
||||||
|
// offers only the placeholder — never a cross-provider row.
|
||||||
|
const models = (harness.catalog?.models ?? []).filter(
|
||||||
|
(model) => model.providerId === harness.providerId,
|
||||||
|
);
|
||||||
|
// A collision-safe composite option identity covering the full provider+model
|
||||||
|
// tuple. The controlled select mirrors the same identity so the exact catalog
|
||||||
|
// row highlights (a bare modelId would collide across providers).
|
||||||
|
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
||||||
|
`${model.providerId}:${model.modelId}`;
|
||||||
|
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -51,21 +80,68 @@ export function Composer({
|
|||||||
className="flex flex-col gap-2 border-t p-4"
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<input
|
<select
|
||||||
|
aria-label="Harness"
|
||||||
|
value={harness.harnessId}
|
||||||
|
onChange={(event) => harness.selectHarness(event.target.value)}
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">Select a harness…</option>
|
||||||
|
{harness.harnesses.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
aria-label="Provider"
|
aria-label="Provider"
|
||||||
value={provider}
|
value={harness.providerId}
|
||||||
onChange={(event) => setProvider(event.target.value)}
|
onChange={(event) => harness.selectProvider(event.target.value)}
|
||||||
placeholder="Provider (optional)"
|
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
className="rounded border px-2 py-1 text-xs"
|
||||||
/>
|
>
|
||||||
<input
|
<option value="">Select a provider…</option>
|
||||||
|
{providerOptions(harness).map((providerId) => (
|
||||||
|
<option key={providerId} value={providerId}>
|
||||||
|
{providerId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
aria-label="Model"
|
aria-label="Model"
|
||||||
value={modelId}
|
value={selectedModelValue}
|
||||||
onChange={(event) => setModelId(event.target.value)}
|
onChange={(event) => {
|
||||||
placeholder="Model (optional)"
|
// Resolve the composite option identity back to the exact catalog
|
||||||
|
// row and persist that row's own provider+model — never a bare id.
|
||||||
|
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
||||||
|
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
||||||
|
}}
|
||||||
|
disabled={harness.catalogUnavailable || models.length === 0}
|
||||||
className="rounded border px-2 py-1 text-xs"
|
className="rounded border px-2 py-1 text-xs"
|
||||||
/>
|
>
|
||||||
|
<option value="">Select a model…</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
||||||
|
{model.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{harness.catalogUnavailable ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
This harness catalog is currently unavailable.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.isStale ? (
|
||||||
|
<p role="status" className="text-xs opacity-70">
|
||||||
|
The saved model is no longer available — pick another to continue.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{harness.persistError ? (
|
||||||
|
<p role="alert" className="text-xs">
|
||||||
|
{harness.persistError.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
@@ -78,7 +154,7 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!content.trim() || busy}
|
disabled={!content.trim() || busy || !harness.canSend}
|
||||||
className="rounded px-3 py-2 text-sm font-medium"
|
className="rounded px-3 py-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||||
* child.
|
* child.
|
||||||
*/
|
*/
|
||||||
|
import type {
|
||||||
|
HarnessAuthState,
|
||||||
|
HarnessCatalog,
|
||||||
|
HarnessCatalogEntry,
|
||||||
|
HarnessModelAvailability,
|
||||||
|
HarnessSelection,
|
||||||
|
HarnessSummary,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
export function asString(value: unknown, fallback = ''): string {
|
export function asString(value: unknown, fallback = ''): string {
|
||||||
return typeof value === 'string' ? value : fallback;
|
return typeof value === 'string' ? value : fallback;
|
||||||
@@ -38,6 +46,99 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
||||||
|
* payloads above — a misbehaving or compromised gateway can send anything. The
|
||||||
|
* guards below normalize those bodies into the typed client shapes without ever
|
||||||
|
* rendering a raw body, so a 404/422/malformed response can never inject an
|
||||||
|
* object into React or a non-tuple into the selection state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
||||||
|
* safest value (`unavailable`) for anything unrecognized. */
|
||||||
|
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
||||||
|
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
||||||
|
? value
|
||||||
|
: 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
||||||
|
* `unavailable` so a malformed row can never present as sendable. */
|
||||||
|
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
||||||
|
return value === 'available' ? 'available' : 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
||||||
|
* or malformed selection is rejected (null) rather than half-adopted. */
|
||||||
|
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const harnessId = value.harnessId;
|
||||||
|
const providerId = value.providerId;
|
||||||
|
const modelId = value.modelId;
|
||||||
|
if (
|
||||||
|
typeof harnessId !== 'string' ||
|
||||||
|
typeof providerId !== 'string' ||
|
||||||
|
typeof modelId !== 'string' ||
|
||||||
|
harnessId.length === 0 ||
|
||||||
|
providerId.length === 0 ||
|
||||||
|
modelId.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { harnessId, providerId, modelId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
||||||
|
* without a usable id. */
|
||||||
|
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: HarnessSummary[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const id = asString(item.id);
|
||||||
|
if (id.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
displayName: asNonEmptyString(item.displayName, id),
|
||||||
|
capabilities: asStringArray(item.capabilities),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
||||||
|
const selection = asHarnessSelection(value);
|
||||||
|
if (selection === null || !isRecord(value)) return null;
|
||||||
|
return {
|
||||||
|
...selection,
|
||||||
|
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
||||||
|
reasoningCapability: value.reasoningCapability === true,
|
||||||
|
inputTypes: asStringArray(value.inputTypes),
|
||||||
|
authState: asHarnessAuthState(value.authState),
|
||||||
|
availability: asHarnessAvailability(value.availability),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
||||||
|
* caller supplies `harnessId` (from the request path) so the returned catalog
|
||||||
|
* is scoped to the harness that was actually requested, never a body-echoed id.
|
||||||
|
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
||||||
|
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
||||||
|
const record = isRecord(value) ? value : {};
|
||||||
|
const rawModels = Array.isArray(record.models) ? record.models : [];
|
||||||
|
const models: HarnessCatalogEntry[] = [];
|
||||||
|
for (const row of rawModels) {
|
||||||
|
const entry = asHarnessCatalogEntry(row);
|
||||||
|
if (entry !== null) models.push(entry);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
harnessId,
|
||||||
|
version: asString(record.version),
|
||||||
|
fingerprint: asString(record.fingerprint),
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The single point of truth for what counts as a valid conversation ID
|
/** The single point of truth for what counts as a valid conversation ID
|
||||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||||
* string, nothing else. Every site that establishes or compares
|
* string, nothing else. Every site that establishes or compares
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { act, type ReactElement } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Scenario {
|
||||||
|
harnesses?: unknown;
|
||||||
|
catalog?: { body: unknown; status?: number };
|
||||||
|
selection?: unknown;
|
||||||
|
/** When set, the PUT resolves only when this is called (for race tests). */
|
||||||
|
deferPut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defer<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
let putBodies: unknown[] = [];
|
||||||
|
let putDeferred: Deferred<Response> | null = null;
|
||||||
|
|
||||||
|
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
||||||
|
putBodies = [];
|
||||||
|
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
const spec = scenario.catalog ?? {
|
||||||
|
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
||||||
|
};
|
||||||
|
return json(spec.body, spec.status ?? 200);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return json({ selection: scenario.selection ?? null });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
putBodies.push(JSON.parse(String(init?.body)));
|
||||||
|
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
if (putDeferred) return putDeferred.promise;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest: HarnessSelectionValue | null = null;
|
||||||
|
|
||||||
|
function Probe(): ReactElement | null {
|
||||||
|
latest = useHarnessSelection();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let root: Root | null;
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
latest = null;
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<Probe />);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(): HarnessSelectionValue {
|
||||||
|
if (!latest) throw new Error('hook value not captured');
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PI_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
displayName: 'Claude',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useHarnessSelection', () => {
|
||||||
|
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
expect(value().catalog).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.harnessId).toBe('pi');
|
||||||
|
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
// Nothing was persisted — no PUT fired for an unset selection.
|
||||||
|
const putCalls = fetchMock.mock.calls.filter(
|
||||||
|
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
||||||
|
);
|
||||||
|
expect(putCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
|
||||||
|
// The persisted tuple is displayed even though its model is gone from the catalog.
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'retired-model',
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('retired-model');
|
||||||
|
expect(value().isStale).toBe(true);
|
||||||
|
// A stale model is not a valid catalog option, so send stays disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalog?.models ?? []).toHaveLength(0);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().catalogUnavailable).toBe(true);
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: {
|
||||||
|
body: {
|
||||||
|
...PI_CATALOG,
|
||||||
|
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
// Override PUT to 422.
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses')
|
||||||
|
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
||||||
|
return json({ selection: null });
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
code: 'model_unavailable',
|
||||||
|
message: 'nope',
|
||||||
|
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response('nf', { status: 404 });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(value().modelId).toBe('gpt-5');
|
||||||
|
expect(value().persistError?.code).toBe('model_unavailable');
|
||||||
|
expect(value().persistError?.requested).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
expect(value().persistedSelection).toBeNull();
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectHarness('pi');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('openai');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('openai', 'gpt-5');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// A valid provider-A tuple has persisted.
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching provider clears the model that no longer belongs to it.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
// Send stays disabled until the new exact provider-B tuple persists.
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().persistedSelection).toEqual({
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
});
|
||||||
|
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||||
|
installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
deferPut: true,
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
expect(value().modelId).toBe('');
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
||||||
|
expect(value().canSend).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
putDeferred?.resolve(
|
||||||
|
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(value().canSend).toBe(true);
|
||||||
|
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||||
|
const fetchMock = installFetch({
|
||||||
|
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||||
|
catalog: { body: PI_CATALOG },
|
||||||
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
await act(async () => {
|
||||||
|
value().selectProvider('anthropic');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
value().selectModel('anthropic', 'claude');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
for (const call of fetchMock.mock.calls) {
|
||||||
|
expect(String(call[0])).not.toContain('/api/providers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchCatalog,
|
||||||
|
fetchHarnesses,
|
||||||
|
fetchPersistedSelection,
|
||||||
|
persistSelection,
|
||||||
|
type SelectionErrorCode,
|
||||||
|
} from './chat-api';
|
||||||
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface HarnessPersistError {
|
||||||
|
code: SelectionErrorCode;
|
||||||
|
message: string;
|
||||||
|
/** The exact tuple the user requested — preserved so the failed selection
|
||||||
|
* stays visible rather than being silently dropped. */
|
||||||
|
requested: HarnessSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarnessSelectionValue {
|
||||||
|
harnesses: HarnessSummary[];
|
||||||
|
catalog: HarnessCatalog | null;
|
||||||
|
/** True when the selected harness has no usable catalog (404/error). */
|
||||||
|
catalogUnavailable: boolean;
|
||||||
|
/** The working (displayed) selection, kept as three distinct ids. Empty
|
||||||
|
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
||||||
|
harnessId: string;
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
/** The last tuple confirmed persisted by the server, or null. */
|
||||||
|
persistedSelection: HarnessSelection | null;
|
||||||
|
/** True when a persisted selection references a model no longer present as an
|
||||||
|
* available catalog entry — it stays visibly displayed rather than dropped. */
|
||||||
|
isStale: boolean;
|
||||||
|
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
||||||
|
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
||||||
|
* can never race ahead of successful persistence. */
|
||||||
|
canSend: boolean;
|
||||||
|
persistError: HarnessPersistError | null;
|
||||||
|
selectHarness: (harnessId: string) => void;
|
||||||
|
selectProvider: (providerId: string) => void;
|
||||||
|
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
||||||
|
* resolves the composite option identity to the real entry and passes both
|
||||||
|
* ids, so a bare model id is never combined with ambient provider state. */
|
||||||
|
selectModel: (providerId: string, modelId: string) => void;
|
||||||
|
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
||||||
|
* send path — derived ONLY from the validated persisted tuple, never from any
|
||||||
|
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
||||||
|
projection: { provider?: string; modelId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||||
|
* matching, available entry — the single gate that keeps a stale/unavailable
|
||||||
|
* model from ever counting as sendable. */
|
||||||
|
function isAvailableInCatalog(
|
||||||
|
selection: HarnessSelection | null,
|
||||||
|
catalog: HarnessCatalog | null,
|
||||||
|
): boolean {
|
||||||
|
if (selection === null || catalog === null) return false;
|
||||||
|
return catalog.models.some(
|
||||||
|
(model) =>
|
||||||
|
model.providerId === selection.providerId &&
|
||||||
|
model.modelId === selection.modelId &&
|
||||||
|
model.availability === 'available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the harness/catalog/selection state for the chat composer: loads the
|
||||||
|
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
||||||
|
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
||||||
|
* when a model is picked. It never auto-selects a catalog row, keeps a
|
||||||
|
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
||||||
|
* once a full tuple has actually persisted as an available catalog entry.
|
||||||
|
*/
|
||||||
|
export function useHarnessSelection(): HarnessSelectionValue {
|
||||||
|
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
||||||
|
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
||||||
|
const [harnessId, setHarnessId] = useState('');
|
||||||
|
const [providerId, setProviderId] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
||||||
|
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
||||||
|
|
||||||
|
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
||||||
|
// overwrite the result of a newer request the user has since triggered.
|
||||||
|
const catalogRequestRef = useRef(0);
|
||||||
|
const persistRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
||||||
|
const requestId = catalogRequestRef.current + 1;
|
||||||
|
catalogRequestRef.current = requestId;
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
const result = await fetchCatalog(id);
|
||||||
|
if (catalogRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setCatalog(result.catalog);
|
||||||
|
setCatalogUnavailable(false);
|
||||||
|
} else {
|
||||||
|
setCatalog(null);
|
||||||
|
setCatalogUnavailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
||||||
|
if (!active) return;
|
||||||
|
setHarnesses(list);
|
||||||
|
if (persisted !== null) {
|
||||||
|
// Adopt the persisted tuple as the displayed selection and load its
|
||||||
|
// catalog. If the model has since been retired, it still shows (stale).
|
||||||
|
setHarnessId(persisted.harnessId);
|
||||||
|
setProviderId(persisted.providerId);
|
||||||
|
setModelId(persisted.modelId);
|
||||||
|
setPersistedSelection(persisted);
|
||||||
|
await loadCatalog(persisted.harnessId);
|
||||||
|
}
|
||||||
|
// No persisted selection → nothing is auto-selected; the user must choose.
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
const selectHarness = useCallback(
|
||||||
|
(id: string): void => {
|
||||||
|
setHarnessId(id);
|
||||||
|
// Changing harness invalidates the provider/model draft — never carry a
|
||||||
|
// model across harnesses.
|
||||||
|
setProviderId('');
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
void loadCatalog(id);
|
||||||
|
},
|
||||||
|
[loadCatalog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProvider = useCallback((id: string): void => {
|
||||||
|
setProviderId(id);
|
||||||
|
// A new provider invalidates the chosen model — no cross-provider carryover.
|
||||||
|
setModelId('');
|
||||||
|
setPersistError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectModel = useCallback(
|
||||||
|
(selectedProviderId: string, selectedModelId: string): void => {
|
||||||
|
// Bind the model to the EXACT catalog row's provider — never to ambient
|
||||||
|
// provider state — so two providers exposing the same modelId can never
|
||||||
|
// collide or mis-resolve. Keep the displayed provider consistent with the
|
||||||
|
// resolved row.
|
||||||
|
setProviderId(selectedProviderId);
|
||||||
|
setModelId(selectedModelId);
|
||||||
|
setPersistError(null);
|
||||||
|
const requested: HarnessSelection = {
|
||||||
|
harnessId,
|
||||||
|
providerId: selectedProviderId,
|
||||||
|
modelId: selectedModelId,
|
||||||
|
};
|
||||||
|
const requestId = persistRequestRef.current + 1;
|
||||||
|
persistRequestRef.current = requestId;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
const result = await persistSelection(requested);
|
||||||
|
if (persistRequestRef.current !== requestId) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setPersistedSelection(result.selection);
|
||||||
|
setPersistError(null);
|
||||||
|
} else {
|
||||||
|
// Leave persistedSelection unchanged (send stays disabled) and surface
|
||||||
|
// the typed error carrying the exact requested tuple.
|
||||||
|
setPersistError({
|
||||||
|
code: result.code,
|
||||||
|
message: result.message,
|
||||||
|
requested: result.requested,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[harnessId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
||||||
|
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
const canSend =
|
||||||
|
persistedSelection !== null &&
|
||||||
|
!catalogUnavailable &&
|
||||||
|
tuplesEqual(draft, persistedSelection) &&
|
||||||
|
isAvailableInCatalog(persistedSelection, catalog);
|
||||||
|
const projection: { provider?: string; modelId?: string } = canSend
|
||||||
|
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
harnesses,
|
||||||
|
catalog,
|
||||||
|
catalogUnavailable,
|
||||||
|
harnessId,
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
persistedSelection,
|
||||||
|
isStale,
|
||||||
|
canSend,
|
||||||
|
persistError,
|
||||||
|
selectHarness,
|
||||||
|
selectProvider,
|
||||||
|
selectModel,
|
||||||
|
projection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,76 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'openai',
|
||||||
|
modelId: 'gpt-5',
|
||||||
|
displayName: 'GPT-5',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
displayName: 'Claude',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A harness/catalog/selection HTTP stub for the chat-api the selection hook
|
||||||
|
* drives. `selection` seeds the persisted tuple returned by the GET (a valid
|
||||||
|
* in-catalog tuple by default, so `canSend` settles true after mount). */
|
||||||
|
function harnessFetch(
|
||||||
|
selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||||
|
): typeof fetch {
|
||||||
|
return vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') {
|
||||||
|
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
return jsonResponse(DEFAULT_CATALOG);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return jsonResponse({ selection });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains the selection hook's chained mount fetches (harnesses → selection →
|
||||||
|
* catalog) and any pending PUT so derived `canSend` settles before assertions. */
|
||||||
|
async function flushAsync(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
let root: Root | null;
|
let root: Root | null;
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
@@ -57,12 +127,16 @@ beforeEach(async () => {
|
|||||||
fake = createFakeChatSocket();
|
fake = createFakeChatSocket();
|
||||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
destroySocketMock.mockReset();
|
destroySocketMock.mockReset();
|
||||||
|
vi.stubGlobal('fetch', harnessFetch());
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<ChatPage />);
|
root?.render(<ChatPage />);
|
||||||
});
|
});
|
||||||
|
// Settle the selection hook's mount fetches so the default in-catalog tuple
|
||||||
|
// persists and `canSend` is true for the existing send-path tests.
|
||||||
|
await flushAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -70,8 +144,23 @@ afterEach(async () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
});
|
});
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for
|
||||||
|
* tests that need a non-default selection scenario. */
|
||||||
|
async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchImpl);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<ChatPage />);
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
describe('ChatPage', () => {
|
||||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -390,24 +479,62 @@ describe('ChatPage', () => {
|
|||||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => {
|
||||||
|
// The old free-text inputs are gone.
|
||||||
|
expect(container.querySelector('input[aria-label="Provider"]')).toBeNull();
|
||||||
|
expect(container.querySelector('input[aria-label="Model"]')).toBeNull();
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
expect(harnessSelect).toBeTruthy();
|
||||||
|
expect(providerSelect).toBeTruthy();
|
||||||
|
expect(modelSelect).toBeTruthy();
|
||||||
|
// Harness and provider are distinct controls carrying distinct identifiers.
|
||||||
|
expect(harnessSelect).not.toBe(providerSelect);
|
||||||
|
expect([...harnessSelect.options].map((o) => o.value)).toContain('pi');
|
||||||
|
expect([...providerSelect.options].map((o) => o.value)).toContain('openai');
|
||||||
|
expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic');
|
||||||
|
// The model options are catalog-derived (not hardcoded) and scoped to the
|
||||||
|
// selected provider (openai, from the persisted tuple) using a collision-safe
|
||||||
|
// composite identity — the anthropic row is absent, not a bare 'claude'.
|
||||||
|
const modelValues = [...modelSelect.options].map((o) => o.value);
|
||||||
|
expect(modelValues).toContain('openai:gpt-5');
|
||||||
|
expect(modelValues).not.toContain('anthropic:claude');
|
||||||
|
expect(modelValues).not.toContain('claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => {
|
||||||
const textarea = container.querySelector(
|
const textarea = container.querySelector(
|
||||||
'textarea[aria-label="Message"]',
|
'textarea[aria-label="Message"]',
|
||||||
) as HTMLTextAreaElement;
|
) as HTMLTextAreaElement;
|
||||||
const providerInput = container.querySelector(
|
|
||||||
'input[aria-label="Provider"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
|
||||||
|
|
||||||
const stopButtonBefore = container.querySelector(
|
const stopButtonBefore = container.querySelector(
|
||||||
'button[aria-label="Stop"]',
|
'button[aria-label="Stop"]',
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
expect(stopButtonBefore.disabled).toBe(true);
|
expect(stopButtonBefore.disabled).toBe(true);
|
||||||
|
|
||||||
|
// Choose a fresh tuple from the catalog and let it persist.
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'anthropic');
|
||||||
|
});
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
// Composite provider+model option identity (provider was switched to
|
||||||
|
// anthropic above); the bare 'claude' no longer identifies an option.
|
||||||
|
selectValue(modelSelect, 'anthropic:claude');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
setValue(textarea, 'hello there');
|
setValue(textarea, 'hello there');
|
||||||
setValue(providerInput, 'anthropic');
|
|
||||||
setValue(modelInput, 'claude');
|
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
textarea.dispatchEvent(
|
textarea.dispatchEvent(
|
||||||
@@ -415,6 +542,7 @@ describe('ChatPage', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The projected provider/model come from the validated persisted tuple.
|
||||||
expect(fake.emitted).toContainEqual({
|
expect(fake.emitted).toContainEqual({
|
||||||
event: 'message',
|
event: 'message',
|
||||||
payload: {
|
payload: {
|
||||||
@@ -443,6 +571,28 @@ describe('ChatPage', () => {
|
|||||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables send until a selection has persisted — no send with an unset selection', async () => {
|
||||||
|
await remountWithFetch(harnessFetch(null));
|
||||||
|
|
||||||
|
const sendButton = findButton(container, 'Send');
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'should not send');
|
||||||
|
});
|
||||||
|
// Content present, but no selection persisted → Send stays disabled.
|
||||||
|
expect(sendButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||||
const textarea = container.querySelector(
|
const textarea = container.querySelector(
|
||||||
'textarea[aria-label="Message"]',
|
'textarea[aria-label="Message"]',
|
||||||
@@ -620,6 +770,134 @@ describe('ChatPage', () => {
|
|||||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => {
|
||||||
|
await remountWithFetch(harnessFetch(null));
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(harnessSelect, 'pi');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||||
|
// Only the selected provider's models are offered — provider B's model
|
||||||
|
// (anthropic:claude) is absent, so a user cannot pick across providers.
|
||||||
|
expect(optionValues).toEqual(['openai:gpt-5']);
|
||||||
|
expect(optionValues).not.toContain('anthropic:claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => {
|
||||||
|
const COLLIDING_CATALOG = {
|
||||||
|
harnessId: 'pi',
|
||||||
|
version: '2026-08-11',
|
||||||
|
fingerprint: 'fp',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'alpha',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
displayName: 'Alpha GPT-X',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
harnessId: 'pi',
|
||||||
|
providerId: 'beta',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
displayName: 'Beta GPT-X',
|
||||||
|
reasoningCapability: true,
|
||||||
|
inputTypes: ['text'],
|
||||||
|
authState: 'ready',
|
||||||
|
availability: 'available',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (url === '/api/harnesses') {
|
||||||
|
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||||
|
return jsonResponse(COLLIDING_CATALOG);
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||||
|
return jsonResponse({ selection: null });
|
||||||
|
}
|
||||||
|
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||||
|
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||||
|
}
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await remountWithFetch(collidingFetch);
|
||||||
|
|
||||||
|
const harnessSelect = container.querySelector(
|
||||||
|
'select[aria-label="Harness"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(harnessSelect, 'pi');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const providerSelect = container.querySelector(
|
||||||
|
'select[aria-label="Provider"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(providerSelect, 'alpha');
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||||
|
// The colliding modelId is provider-qualified in the option value, never a
|
||||||
|
// bare id, so the two providers' 'gpt-x' rows are uniquely identifiable.
|
||||||
|
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||||
|
expect(optionValues).toEqual(['alpha:gpt-x']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(modelSelect, 'alpha:gpt-x');
|
||||||
|
});
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
// The controlled select highlights the alpha row via the composite identity.
|
||||||
|
expect(modelSelect.value).toBe('alpha:gpt-x');
|
||||||
|
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'ping');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The persisted/sent tuple resolves to provider alpha — NOT beta — even
|
||||||
|
// though the bare modelId 'gpt-x' exists under both providers.
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'ping',
|
||||||
|
provider: 'alpha',
|
||||||
|
modelId: 'gpt-x',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
|||||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||||
|
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
|
||||||
|
|
||||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||||
@@ -23,6 +24,7 @@ function formatCost(value: unknown): string {
|
|||||||
|
|
||||||
export function ChatPage(): ReactElement {
|
export function ChatPage(): ReactElement {
|
||||||
const { state, actions } = useChatConnection();
|
const { state, actions } = useChatConnection();
|
||||||
|
const harness = useHarnessSelection();
|
||||||
const hasConversation = state.conversationId !== null;
|
const hasConversation = state.conversationId !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +88,7 @@ export function ChatPage(): ReactElement {
|
|||||||
streaming={state.streaming}
|
streaming={state.streaming}
|
||||||
sending={state.sending}
|
sending={state.sending}
|
||||||
hasConversation={hasConversation}
|
hasConversation={hasConversation}
|
||||||
|
harness={harness}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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>
|
||||||
|
>();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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];
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './contracts.js';
|
||||||
|
export * from './events.js';
|
||||||
|
export * from './errors.js';
|
||||||
@@ -8,3 +8,4 @@ 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