ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
1128 lines
40 KiB
TypeScript
1128 lines
40 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();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Deterministic idempotency key for the Task Five red-first page send test. */
|
|
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
|
|
|
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
|
|
* defineProperty on the crypto instance so it works whether or not the native
|
|
* method is configurable (it lives on the prototype; an own property shadows it). */
|
|
function installRandomUUID(fn: () => string): () => void {
|
|
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
if (!g.crypto) {
|
|
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
|
}
|
|
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
|
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
|
Object.defineProperty(cryptoObj, 'randomUUID', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: fn,
|
|
});
|
|
return () => {
|
|
if (original) {
|
|
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
|
} else {
|
|
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only
|
|
* as the server advertised, once per connection, over the server-to-client-only
|
|
* `chat:send-capability`. Model that advertisement for THIS connection id so the
|
|
* page send tests take the intended branch. `legacy-message` is the default
|
|
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
|
|
* reset the generation and re-advertise `turn-send` via the helper below.
|
|
*/
|
|
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
|
|
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
|
|
}
|
|
|
|
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
|
|
* default `legacy-message` advertisement + first-wins lock), then advertise the
|
|
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
|
|
* for the page send tests that route through `turn:send`. */
|
|
async function advertiseTurnSendGeneration(): Promise<void> {
|
|
await act(async () => {
|
|
fake.simulateReconnect();
|
|
});
|
|
await act(async () => {
|
|
advertiseSendCapability('turn-send');
|
|
});
|
|
}
|
|
|
|
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();
|
|
// Model the server's post-auth send-capability advertisement (MAJOR-1). Most
|
|
// page send tests exercise the legacy `message` branch; the pi turn-runtime
|
|
// tests override to `turn-send` via advertiseTurnSendGeneration().
|
|
await act(async () => {
|
|
advertiseSendCapability('legacy-message');
|
|
});
|
|
});
|
|
|
|
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();
|
|
// Re-advertise on the remounted connection — the prior generation's capability
|
|
// does not carry across a remount (fresh hook instance, unadvertised protocol).
|
|
await act(async () => {
|
|
advertiseSendCapability('legacy-message');
|
|
});
|
|
}
|
|
|
|
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('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => {
|
|
await advertiseTurnSendGeneration();
|
|
const restore = installRandomUUID(() => PAGE_UUID);
|
|
try {
|
|
// Send is disabled without an active conversation — establish one first.
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
setValue(textarea, 'hello there');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
} finally {
|
|
restore();
|
|
}
|
|
|
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
|
expect(sends).toHaveLength(1);
|
|
expect(sends[0]?.payload).toEqual({
|
|
conversationId: 'c1',
|
|
content: 'hello there',
|
|
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
|
idempotencyKey: PAGE_UUID,
|
|
});
|
|
// The pi-rpc page send must not emit the embedded `message` event, and must
|
|
// never send a flat {provider, modelId} that drops the harnessId.
|
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
|
});
|
|
|
|
it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => {
|
|
await advertiseTurnSendGeneration();
|
|
const failing = installRandomUUID(() => {
|
|
throw new Error('secure random unavailable');
|
|
});
|
|
try {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
setValue(textarea, 'keep me');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
// No wire traffic: neither the harness turn nor the legacy message.
|
|
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
|
// The composer retained its content — it clears ONLY on a successful send,
|
|
// so the user can retry without retyping.
|
|
expect(textarea.value).toBe('keep me');
|
|
// A visible, safe notice explains why nothing was sent.
|
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
} finally {
|
|
failing();
|
|
}
|
|
});
|
|
|
|
it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => {
|
|
await advertiseTurnSendGeneration();
|
|
const restore = installRandomUUID(() => PAGE_UUID);
|
|
try {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
setValue(textarea, 'ship it');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
|
expect(sends).toHaveLength(1);
|
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
|
// On a successful send the composer clears.
|
|
expect(textarea.value).toBe('');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => {
|
|
await advertiseTurnSendGeneration();
|
|
const restore = installRandomUUID(() => PAGE_UUID);
|
|
try {
|
|
// Change the selection away from the mount default 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 () => {
|
|
selectValue(modelSelect, 'anthropic:claude');
|
|
});
|
|
await flushAsync();
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
await act(async () => {
|
|
setValue(textarea, 'routed');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
|
expect(sends).toHaveLength(1);
|
|
// The nested tuple reflects the CURRENTLY persisted selection, not the
|
|
// mount default {openai, gpt-5}, and never flat provider/model fields.
|
|
expect(sends[0]?.payload).toEqual({
|
|
conversationId: 'c1',
|
|
content: 'routed',
|
|
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
|
idempotencyKey: PAGE_UUID,
|
|
});
|
|
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|