ci/woodpecker/pr/ci Pipeline was successful
Fix-in-lane for the two Task-4 review findings, red-first TDD, inside the
existing nine-file fence.
M-A: the composer now filters model <option>s to the intentionally selected
provider (harness.providerId). With no provider chosen the model select offers
only its placeholder, so a user can never pick a model that belongs to a
different provider.
M-B: the model <option> value is now the collision-safe composite
`${providerId}:${modelId}` (was the bare modelId), the controlled select value
mirrors that same identity so the exact catalog row highlights, and the change
handler resolves the composite back to the exact catalog row and persists that
row's own {harnessId, providerId, modelId}. selectModel(providerId, modelId) no
longer combines a bare model id with ambient provider state, so two providers
exposing the same modelId stay distinct.
New red-first tests prove: cross-provider models absent, identical modelIds
under two providers stay distinct and resolve to the intended tuple, a provider
change invalidates the old model and keeps send disabled until the new tuple
persists, and a model pick does not enable send until its exact PUT resolves.
The four existing anti-masking invariants and the 422 tuple-preservation test
are intact.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
915 lines
31 KiB
TypeScript
915 lines
31 KiB
TypeScript
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
|
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
|
|
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
|
getSocketMock: vi.fn(),
|
|
destroySocketMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/socket', () => ({
|
|
getSocket: getSocketMock,
|
|
destroySocket: destroySocketMock,
|
|
}));
|
|
|
|
import { ChatPage } from './chat';
|
|
|
|
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
const proto =
|
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
setter?.call(el, value);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function selectValue(el: HTMLSelectElement, value: string): void {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
setter?.call(el, value);
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
candidate.textContent?.includes(text),
|
|
);
|
|
if (!button) throw new Error(`Button with text "${text}" not found`);
|
|
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 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(async () => {
|
|
fake = createFakeChatSocket();
|
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
|
destroySocketMock.mockReset();
|
|
vi.stubGlobal('fetch', harnessFetch());
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
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 () => {
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
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', () => {
|
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
|
fake.serverEmit('agent:tool:start', {
|
|
conversationId: 'c1',
|
|
toolCallId: 't1',
|
|
toolName: 'web_search',
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toContain('pondering…');
|
|
expect(container.textContent).toContain('Hello!');
|
|
expect(container.textContent).toContain('web_search');
|
|
expect(container.textContent).toMatch(/running/i);
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('agent:tool:end', {
|
|
conversationId: 'c1',
|
|
toolCallId: 't1',
|
|
toolName: 'web_search',
|
|
isError: false,
|
|
});
|
|
fake.serverEmit('agent:end', {
|
|
conversationId: 'c1',
|
|
usage: {
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
|
cost: 0.02,
|
|
context: { percent: 3, window: 200000 },
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toMatch(/success/i);
|
|
expect(container.textContent).toContain('Hello!');
|
|
expect(container.textContent).toMatch(/46/);
|
|
});
|
|
|
|
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('commands:manifest', {
|
|
manifest: {
|
|
commands: [
|
|
{
|
|
name: 'model',
|
|
aliases: ['m'],
|
|
description: 'Change the active model',
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
],
|
|
skills: [],
|
|
version: 1,
|
|
},
|
|
});
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
routingDecision: {
|
|
model: 'claude',
|
|
provider: 'anthropic',
|
|
ruleName: 'default',
|
|
reason: 'default routing',
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toContain('model');
|
|
expect(container.textContent).toContain('Change the active model');
|
|
expect(container.textContent).toContain('anthropic');
|
|
expect(container.textContent).toContain('default routing');
|
|
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
|
|
|
await act(async () => {
|
|
selectValue(select, 'high');
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'set:thinking',
|
|
payload: { conversationId: 'c1', level: 'high' },
|
|
});
|
|
});
|
|
|
|
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const commandInput = container.querySelector(
|
|
'input[aria-label="Command name"]',
|
|
) as HTMLInputElement;
|
|
const argsInput = container.querySelector(
|
|
'input[aria-label="Command arguments"]',
|
|
) as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'model');
|
|
setValue(argsInput, 'gpt-5');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Run command').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
|
});
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'deploy');
|
|
setValue(argsInput, 'prod');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:approve',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
});
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('command:approval', {
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toMatch(/approved/i);
|
|
|
|
await act(async () => {
|
|
findButton(container, 'Run approved command').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
});
|
|
});
|
|
|
|
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
|
fake.serverEmit('command:result', {
|
|
conversationId: 'c1',
|
|
command: 'model',
|
|
success: false,
|
|
message: 'Unknown model',
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
expect(alertText).toContain('The model is unavailable');
|
|
// The structured, contract-provided denial reason is visibly rendered.
|
|
expect(alertText).toContain('Unknown model');
|
|
});
|
|
|
|
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('command:result', {
|
|
conversationId: 'c1',
|
|
command: 'model',
|
|
success: false,
|
|
message: { bad: 'object' },
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
expect(alertText).toContain('Command failed.');
|
|
});
|
|
|
|
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
|
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'level-0',
|
|
availableThinkingLevels: hostileLevels,
|
|
});
|
|
});
|
|
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
|
});
|
|
|
|
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: null,
|
|
});
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
// A malformed level list still shows a visible, safe placeholder option
|
|
// rather than a silently empty select.
|
|
expect([...select.options]).toHaveLength(1);
|
|
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
|
|
|
await act(async () => {
|
|
selectValue(select, '');
|
|
});
|
|
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
|
});
|
|
|
|
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
fake.serverEmitRaw('agent:end', {
|
|
conversationId: 'c1',
|
|
usage: {
|
|
provider: { nested: 'object' },
|
|
modelId: undefined,
|
|
thinkingLevel: 'medium',
|
|
tokens: { total: 'not-a-number' },
|
|
cost: undefined,
|
|
context: { percent: null, window: 200000 },
|
|
},
|
|
});
|
|
});
|
|
|
|
const usage = container.querySelector('[aria-label="Usage"]');
|
|
expect(usage).toBeTruthy();
|
|
expect(usage?.textContent).toContain('tokens unavailable');
|
|
expect(usage?.textContent).toContain('cost unavailable');
|
|
expect(usage?.textContent).not.toContain('0 tokens');
|
|
expect(usage?.textContent).not.toContain('$0.0000');
|
|
expect(usage?.textContent).toContain('unknown/unknown');
|
|
});
|
|
|
|
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
|
node.textContent?.includes('Message accepted'),
|
|
);
|
|
expect(status).toBeTruthy();
|
|
// A malformed messageId gets a stable, visible fallback — never blank,
|
|
// never the raw object.
|
|
expect(status?.textContent).toContain('unknown');
|
|
});
|
|
|
|
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmitRaw('system:reload', {
|
|
commands: [],
|
|
skills: [],
|
|
providers: [],
|
|
message: { bad: 'object' },
|
|
});
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
const status = container.querySelector('[role="status"]');
|
|
expect(status).toBeTruthy();
|
|
// A malformed reload message renders a stable, visible fallback rather
|
|
// than a silently empty status line.
|
|
expect(status?.textContent).toContain('Commands reloaded.');
|
|
});
|
|
|
|
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
});
|
|
|
|
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(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
const stopButtonBefore = container.querySelector(
|
|
'button[aria-label="Stop"]',
|
|
) as HTMLButtonElement;
|
|
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 () => {
|
|
setValue(textarea, 'hello there');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
// The projected provider/model come from the validated persisted tuple.
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'message',
|
|
payload: {
|
|
conversationId: undefined,
|
|
content: 'hello there',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
},
|
|
});
|
|
expect(container.textContent).toContain('hello there');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
|
|
const stopButtonDuring = container.querySelector(
|
|
'button[aria-label="Stop"]',
|
|
) as HTMLButtonElement;
|
|
expect(stopButtonDuring.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
stopButtonDuring.click();
|
|
});
|
|
|
|
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 () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'hello');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
});
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('anthropic');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('anthropic');
|
|
});
|
|
|
|
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'hello');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('error', {
|
|
conversationId: 'c1',
|
|
error: 'Failed to start agent session. Please try again.',
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
|
true,
|
|
);
|
|
|
|
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
|
expect(stopButton.disabled).toBe(true);
|
|
});
|
|
|
|
it('shows an accessible status once the message is acknowledged', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const statuses = [...container.querySelectorAll('[role="status"]')];
|
|
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
|
});
|
|
|
|
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
|
});
|
|
|
|
expect(container.textContent).toContain('reasoning about it');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
|
});
|
|
|
|
expect(container.textContent).toContain('reasoning about it');
|
|
expect(container.textContent).toContain('Done.');
|
|
});
|
|
|
|
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const commandInput = container.querySelector(
|
|
'input[aria-label="Command name"]',
|
|
) as HTMLInputElement;
|
|
const argsInput = container.querySelector(
|
|
'input[aria-label="Command arguments"]',
|
|
) as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'deploy');
|
|
setValue(argsInput, 'prod');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
await act(async () => {
|
|
setValue(argsInput, 'staging');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
|
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:approve',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
});
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('command:approval', {
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
findButton(container, 'Run approved command').click();
|
|
findButton(container, 'Run approved command').click();
|
|
});
|
|
|
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
});
|
|
});
|
|
|
|
it('disables sending a second message while a turn is streaming', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'first');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
|
|
const sendButton = findButton(container, 'Send');
|
|
expect(sendButton.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'second');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
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 () => {
|
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
|
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
|
|
for (const [, handlers] of fake.listeners) {
|
|
expect(handlers.size).toBe(0);
|
|
}
|
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
|
});
|
|
});
|