Files
stack/apps/web/src/spa/chat/use-harness-selection.spec.tsx
T

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');
}
});
});