Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
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);
|
|
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
|
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
|
// harnessId must never be dropped on the way to the wire.
|
|
expect('projection' in value()).toBe(false);
|
|
});
|
|
|
|
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',
|
|
});
|
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
|
expect('projection' in value()).toBe(false);
|
|
});
|
|
|
|
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);
|
|
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
|
expect('projection' in value()).toBe(false);
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|
|
});
|