refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
This commit was merged in pull request #1172.
This commit is contained in:
@@ -10,11 +10,16 @@ import type {
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
@@ -37,11 +42,16 @@ export type {
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||
import type { HarnessSelection } from '@/lib/types';
|
||||
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
@@ -44,11 +45,17 @@ export function Composer({
|
||||
if (busy) return;
|
||||
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||
// selection can never emit, so provider/model never travel as free text.
|
||||
if (!harness.canSend) return;
|
||||
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
onSend({ content: trimmed, ...harness.projection });
|
||||
setContent('');
|
||||
// Pass the validated, persisted selection tuple only. The hook derives the
|
||||
// wire projection (legacy `message` provider/model, or `turn:send`) from the
|
||||
// negotiated `chat:send-capability` protocol — never from flat caller input.
|
||||
const selection = harness.persistedSelection;
|
||||
const ok = onSend({ content: trimmed, selection });
|
||||
// Clear the input only when the send was accepted — a refused turn (e.g. a
|
||||
// failed idempotency mint) must retain the user's text so it is not lost.
|
||||
if (ok) setContent('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
export interface FakeChatSocket {
|
||||
connected: boolean;
|
||||
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
|
||||
* echoes in a `chat:send-capability` payload. The generation-bound send
|
||||
* protocol accepts an advertisement only when `payload.connectionId === id`. */
|
||||
id: string;
|
||||
connect(): FakeChatSocket;
|
||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
@@ -51,8 +55,10 @@ export function createFakeChatSocket(): {
|
||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||
* instance after a transient disconnect: marks the socket connected again
|
||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||
* without clearing or replacing any listeners. */
|
||||
simulateReconnect(): void;
|
||||
* without clearing or replacing any listeners. A real reconnect is assigned
|
||||
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
|
||||
* (defaults to the current id so existing callers are unaffected). */
|
||||
simulateReconnect(nextId?: string): void;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
@@ -63,6 +69,7 @@ export function createFakeChatSocket(): {
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
id: 'socket-a',
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
@@ -105,8 +112,9 @@ export function createFakeChatSocket(): {
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(): void {
|
||||
function simulateReconnect(nextId: string = socket.id): void {
|
||||
socket.connected = true;
|
||||
socket.id = nextId;
|
||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('@/lib/socket', () => ({
|
||||
destroySocket: destroySocketMock,
|
||||
}));
|
||||
|
||||
import type { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types';
|
||||
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
@@ -33,6 +34,126 @@ function Harness(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, Step Two (web send path) red-first support. These probe the FUTURE
|
||||
* pi-rpc send contract against the CURRENT implementation, so the desired API is
|
||||
* expressed here as a localized cast — production types stay untouched until Step
|
||||
* Three. The reds fail on behaviour (legacy `message` emitted instead of
|
||||
* `turn:send`; no nested selection; no idempotency key; void return; no
|
||||
* conversation-id gating), never on a missing module or type.
|
||||
*/
|
||||
interface HarnessTurnSendInput {
|
||||
readonly content: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean;
|
||||
|
||||
function harnessSend(): HarnessSendMessage {
|
||||
return latest?.actions.sendMessage as unknown as HarnessSendMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five MAJOR-1 (browser send-protocol negotiation) support. The Gateway
|
||||
* advertises how this connection may send via a server-to-client-only
|
||||
* `chat:send-capability` (already part of the typed `ServerToClientEvents`
|
||||
* contract, so this uses the fake's typed `serverEmit` — no cast); the hook
|
||||
* holds the advertised protocol and routes `sendMessage` through an exhaustive
|
||||
* switch on it, never inferring it from conversation/selection. When no listener
|
||||
* is registered yet (CURRENT impl), the emit is an inert no-op, so the reds
|
||||
* below fail on BEHAVIOUR — the current send path still infers a protocol and
|
||||
* emits regardless of any advertisement — not on a missing module or type.
|
||||
*/
|
||||
function advertiseCapability(protocol: ChatSendProtocol, connectionId: string): void {
|
||||
fake.serverEmit('chat:send-capability', { protocol, connectionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a controllable `crypto.randomUUID` on the global crypto object and
|
||||
* return a restore fn. Uses defineProperty on the instance so it works whether
|
||||
* or not the native method is configurable (it lives on the prototype, so an own
|
||||
* property simply 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own
|
||||
* `undefined` property. The native method lives on `Crypto.prototype`, so a
|
||||
* bare delete of the (non-existent) own property would leave the inherited
|
||||
* method visible — the shadow is what actually makes the call site see no
|
||||
* secure generator. Returns a restore fn.
|
||||
*/
|
||||
function removeRandomUUID(): () => 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: undefined,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface
|
||||
* and the FUTURE fixed idempotency/rejection notice, expressed as a localized
|
||||
* read-only view over `state`. Production `ChatConnectionState` gains
|
||||
* `turnReceipt` at Step Three; the cast keeps production types untouched until
|
||||
* then, so a success assertion against it fails on BEHAVIOUR (no turn:ack
|
||||
* handler runs), never on a missing module. `error` already exists on state.
|
||||
*/
|
||||
interface HarnessTurnReceiptView {
|
||||
readonly idempotencyKey: string;
|
||||
readonly receiptId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
interface HarnessTurnStateView {
|
||||
readonly turnReceipt: HarnessTurnReceiptView | null | undefined;
|
||||
readonly error: string | null;
|
||||
}
|
||||
function harnessTurnState(): HarnessTurnStateView {
|
||||
return latest?.state as unknown as HarnessTurnStateView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op
|
||||
* today (the fake iterates an empty handler set), so the group-4 reds fail
|
||||
* because nothing is surfaced, not because this throws. The event name is cast
|
||||
* past the compile-time `ServerToClientEvents` contract exactly as the
|
||||
* `turn:send` client cast is; the typed event map lands at Step Three.
|
||||
*/
|
||||
function serverEmitTurnAck(payload: unknown): void {
|
||||
fake.serverEmitRaw('turn:ack' as unknown as Parameters<typeof fake.serverEmitRaw>[0], payload);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
@@ -67,6 +188,20 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('useChatConnection', () => {
|
||||
// Task Five MAJOR-1: the send path is PROTOCOL-driven — `sendMessage` routes
|
||||
// only on the negotiated `chat:send-capability`, never on inferred
|
||||
// conversation/selection state. These pre-existing cases exercise the legacy
|
||||
// `message` branch, so the connection is advertised `legacy-message` once here
|
||||
// (server-to-client, for this exact socket id) after the mount registers its
|
||||
// listener. Sub-describes that need the pi turn-runtime reset the generation
|
||||
// and re-advertise `turn-send`; the capability describe resets to the
|
||||
// unadvertised `unavailable` baseline and drives the protocol itself.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
@@ -344,7 +479,10 @@ describe('useChatConnection', () => {
|
||||
|
||||
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
|
||||
latest?.actions.sendMessage({
|
||||
content: 'hello',
|
||||
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
@@ -373,6 +511,408 @@ describe('useChatConnection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// The pi turn-runtime routes sends through `turn:send`. Reset the generation
|
||||
// (clearing the outer `legacy-message` advertisement + first-wins lock) and
|
||||
// advertise `turn-send` for this exact connection, so every send below takes
|
||||
// the turn-runtime branch.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establishConversation(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
} 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',
|
||||
selection,
|
||||
idempotencyKey: UUID,
|
||||
});
|
||||
// The pi-rpc sender must not fall back to the embedded `message` event.
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
});
|
||||
|
||||
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
|
||||
const gen = vi.fn(() => UUID);
|
||||
const restore = installRandomUUID(gen);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(gen).toHaveBeenCalledTimes(2);
|
||||
const keys = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
expect(keys).toEqual([UUID, UUID]);
|
||||
});
|
||||
|
||||
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'too early', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
// Nothing optimistically appended when the send is refused.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when it emits and false when the send is refused', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let refusedEarly: boolean | undefined;
|
||||
let acceptedAfter: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
refusedEarly = harnessSend()({ content: 'early', selection });
|
||||
});
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
acceptedAfter = harnessSend()({ content: 'now', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(refusedEarly).toBe(false);
|
||||
expect(acceptedAfter).toBe(true);
|
||||
});
|
||||
|
||||
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
|
||||
await establishConversation();
|
||||
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('secure random unavailable');
|
||||
});
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'blocked', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
|
||||
|
||||
// The send lock must have been released, so a subsequent valid send works.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'retry', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// turn:ack is the receipt for a `turn:send`, so these establish under the pi
|
||||
// turn-runtime: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Establish the conversation and send one accepted turn under a controlled
|
||||
// idempotency key. Returns the crypto restore fn so callers unwind it.
|
||||
async function establishAndSend(): Promise<() => void> {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
return restore;
|
||||
}
|
||||
|
||||
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
|
||||
// only when Step Three echoes the exact tuple back into state — never a
|
||||
// substituted or fabricated one.
|
||||
expect(harnessTurnState().turnReceipt).toEqual({
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
|
||||
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: a rejected ack must surface a visible notice; today no
|
||||
// handler runs, so state.error stays null.
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
// The raw internal exception text must never reach the browser surface.
|
||||
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
|
||||
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
|
||||
// A rejection must not fabricate a success receipt tuple.
|
||||
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
|
||||
const firstRestore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-ALPHA adapter_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
firstRestore();
|
||||
}
|
||||
const firstNotice = harnessTurnState().error;
|
||||
|
||||
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
|
||||
// reason, must surface the identical fixed notice.
|
||||
const secondRestore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'again', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-BRAVO conversation_service_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
secondRestore();
|
||||
}
|
||||
const secondNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today; green requires a single frozen safe
|
||||
// string surfaced for both distinct internal causes.
|
||||
expect(firstNotice).toBeTruthy();
|
||||
expect(secondNotice).toBeTruthy();
|
||||
expect(firstNotice).toBe(secondNotice);
|
||||
expect(firstNotice ?? '').not.toContain('ALPHA');
|
||||
expect(secondNotice ?? '').not.toContain('BRAVO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID_A = '11111111-1111-4111-8111-111111111111';
|
||||
const UUID_B = '22222222-2222-4222-9222-222222222222';
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// The idempotency key is minted only on the pi turn-runtime `turn:send`
|
||||
// branch: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establish(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
|
||||
const keys = [UUID_A, UUID_B];
|
||||
let call = 0;
|
||||
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
|
||||
try {
|
||||
await establish();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sent = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
// RED anchor: current sendMessage emits the legacy `message`, so no
|
||||
// turn:send keys exist at all.
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[0]).toMatch(UUID_V4);
|
||||
expect(sent[1]).toMatch(UUID_V4);
|
||||
expect(sent[0]).not.toBe(sent[1]);
|
||||
});
|
||||
|
||||
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
|
||||
await establish();
|
||||
|
||||
const restoreCrypto = removeRandomUUID();
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreCrypto();
|
||||
}
|
||||
|
||||
// RED anchors: a refused send returns false and surfaces a visible notice.
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
|
||||
|
||||
// The lock released synchronously (no server event needed): a later valid
|
||||
// send goes through.
|
||||
const restore = installRandomUUID(() => UUID_A);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'recovered', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
|
||||
// Case 1: absent.
|
||||
await establish();
|
||||
const restoreAbsent = removeRandomUUID();
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'absent', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreAbsent();
|
||||
}
|
||||
const absentNotice = harnessTurnState().error;
|
||||
|
||||
// Case 2: throws with a distinctive internal message.
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'throws', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
const throwNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today.
|
||||
expect(absentNotice).toBeTruthy();
|
||||
expect(throwNotice).toBeTruthy();
|
||||
expect(absentNotice).toBe(throwNotice);
|
||||
// The thrown internal detail must never reach the browser surface.
|
||||
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
|
||||
expect(throwNotice ?? '').not.toContain('entropy pool');
|
||||
});
|
||||
});
|
||||
|
||||
it('abort emits abort with the active conversationId', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
@@ -684,7 +1224,16 @@ describe('useChatConnection', () => {
|
||||
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||
|
||||
// The send lock must also be released — a subsequent sendMessage after
|
||||
// reconnect must not be permanently blocked by the interrupted turn.
|
||||
// reconnect must not be permanently blocked by the interrupted turn. The
|
||||
// disconnect also voids the negotiated send protocol (MAJOR-1), so model the
|
||||
// reconnect handshake — the socket reconnects and the server re-advertises
|
||||
// how this connection may send — before probing the released lock.
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'after reconnect' });
|
||||
});
|
||||
@@ -1665,4 +2214,319 @@ describe('useChatConnection', () => {
|
||||
}
|
||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('chat:send-capability protocol negotiation (Task Five MAJOR-1, red-first)', () => {
|
||||
const capSelection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
// The one fixed, safe user-facing notice the hook must surface (code
|
||||
// `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||
// advertised protocol is `unavailable`/unknown/absent. Contract-frozen string.
|
||||
const UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||
|
||||
// These tests each drive the protocol negotiation themselves, so they must
|
||||
// start from a clean, unadvertised generation. Reconnect resets protocolRef
|
||||
// to `unavailable` and clears the outer `legacy-message` first-wins lock
|
||||
// WITHOUT advertising — no client emit, so `fake.emitted` stays empty and the
|
||||
// "starts unavailable" premise holds.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
});
|
||||
|
||||
async function establishConversation(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
function connectCalls(): number {
|
||||
return (fake.socket.connect as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
|
||||
}
|
||||
|
||||
it('starts with no advertised protocol: a send is refused, emits nothing, mints no key, and surfaces the fixed unavailable notice', async () => {
|
||||
// No `chat:send-capability` has arrived, so the connection has not been told
|
||||
// it may send at all. The current impl infers "selection + no conversation +
|
||||
// no flat provider/model → return false" but SURFACES NOTHING — the red is
|
||||
// that the fixed `send_protocol_unavailable` notice is never set.
|
||||
let uuidCalls = 0;
|
||||
const restore = installRandomUUID(() => {
|
||||
uuidCalls += 1;
|
||||
return UUID;
|
||||
});
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
// The test's name promises "mints no key": the unavailable branch must not
|
||||
// reach the idempotency mint at all. Without this assertion a defect that
|
||||
// mints a key before refusing survives.
|
||||
expect(uuidCalls).toBe(0);
|
||||
// ...and no user content may be optimistically appended on refusal.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'hi')).toBe(false);
|
||||
});
|
||||
|
||||
it('legacy-message advertised overrides conversation-inference: an established conversation still routes the legacy message event, never turn:send', async () => {
|
||||
// Same inputs the inference impl routes to `turn:send` (selection + active
|
||||
// conversation). The advertised protocol is authoritative: it must emit the
|
||||
// legacy `message` event instead. Red: current impl emits turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
it('legacy-message advertised with no conversation: derives provider/model from the selection tuple and emits one message', async () => {
|
||||
// The flat provider/modelId caller inputs are gone; the legacy branch must
|
||||
// source them from the confirmed persisted selection. Red: current impl
|
||||
// refuses a bare harness send (selection + no flat fields → return false).
|
||||
let returned: boolean | undefined;
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'first', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(true);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'first',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
|
||||
it('unavailable advertised: refuses even with an active conversation and selection, emits nothing, surfaces the fixed notice', async () => {
|
||||
// Red: current impl ignores the advertisement and emits turn:send.
|
||||
let uuidCalls = 0;
|
||||
const restore = installRandomUUID(() => {
|
||||
uuidCalls += 1;
|
||||
return UUID;
|
||||
});
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('unavailable', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'nope', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
// Refusal must not optimistically append the user's turn to the transcript
|
||||
// (a distinct leak from the emit): the unavailable branch appends nothing.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'nope')).toBe(false);
|
||||
// ...and must not mint an idempotency key on the refused path.
|
||||
expect(uuidCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores an advertisement whose connectionId does not match the socket id: protocol stays unavailable and the send is refused', async () => {
|
||||
// A capability minted for a different (stale/foreign) connection must never
|
||||
// arm this one. Red: current impl has no connection-id gate and emits
|
||||
// turn:send off the inferred path.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', 'a-different-connection');
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'spoof', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
});
|
||||
|
||||
it('accepts only the first advertisement for the generation: a later conflicting protocol is ignored', async () => {
|
||||
// legacy-message wins; the subsequent turn-send is a replay/conflict and is
|
||||
// dropped. Red: current impl ignores both and infers turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to unavailable on disconnect: a later send is refused and never reconnects the socket', async () => {
|
||||
// Disconnect voids the advertised protocol for the generation. The send must
|
||||
// refuse and MUST NOT call socket.connect() to force a reconnection. Red:
|
||||
// current impl keeps the conversation, infers turn:send, and its turn:send
|
||||
// branch calls socket.connect() when the socket is disconnected.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
let connectsDuringSend = 0;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.simulateDisconnect();
|
||||
});
|
||||
const before = connectCalls();
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'after-drop', selection: capSelection });
|
||||
});
|
||||
connectsDuringSend = connectCalls() - before;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
expect(connectsDuringSend).toBe(0);
|
||||
});
|
||||
|
||||
it('resets on reconnect to a fresh generation: refuses until re-advertised, then honors the new advertisement', async () => {
|
||||
// A reconnect mints a new Socket.id and a new generation; the prior
|
||||
// advertisement (bound to the old id) is stale and must not carry over. The
|
||||
// hook only trusts a fresh advertisement for the new connection. Red:
|
||||
// current impl has no connect listener and keeps inferring turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let refusedAfterReconnect: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.simulateReconnect('socket-b');
|
||||
});
|
||||
await act(async () => {
|
||||
refusedAfterReconnect = harnessSend()({ content: 'stale', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(refusedAfterReconnect).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
|
||||
// A fresh advertisement for the reconnected id (socket-b) re-arms sending.
|
||||
const restore2 = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', 'socket-b');
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'welcome-back', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore2();
|
||||
}
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: 'c1',
|
||||
content: 'welcome-back',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes on the synchronous protocol ref, not the batched reducer mirror: an advertisement and a send in the SAME tick still route by the just-advertised protocol', async () => {
|
||||
// An advertisement lands and a send is issued within one synchronous tick,
|
||||
// before React commits the reducer's `sendProtocol` mirror. The send is
|
||||
// captured from the pre-advertisement render, so its closed-over reducer
|
||||
// state still reads `sendProtocol === 'unavailable'`; the capability
|
||||
// handler, however, has already set the synchronous `protocolRef` to
|
||||
// `legacy-message`. The hook must route on that ref. Red (against a
|
||||
// stale-mirror routing that reads `state.sendProtocol`): the send reads the
|
||||
// pre-advertisement `unavailable` and refuses instead of emitting `message`.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
// Bound to the CURRENT (pre-advertisement) render — its closure still
|
||||
// sees the reset `unavailable` mirror even after the advert dispatches.
|
||||
const sendBeforeCommit = harnessSend();
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
// Same tick, no await: React has not committed the new mirror yet, so
|
||||
// only `protocolRef` reflects `legacy-message`.
|
||||
sendBeforeCommit({ content: 'same-tick', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'same-tick',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
asConversationId,
|
||||
asFiniteNumber,
|
||||
asHarnessSelection,
|
||||
asString,
|
||||
asStringArray,
|
||||
isRecord,
|
||||
@@ -21,10 +22,14 @@ import type {
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
MessageAckPayload,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
@@ -130,6 +135,42 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
|
||||
* dropped. */
|
||||
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
|
||||
|
||||
/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
|
||||
* (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
|
||||
* `code`/`message`/`error` can carry adapter internals or entropy-source detail,
|
||||
* so no rejection ever leaks its cause into the UI — every distinct rejection
|
||||
* shows this same string. */
|
||||
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';
|
||||
|
||||
/** Fixed, browser-safe notice surfaced when a turn is refused because the
|
||||
* idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
|
||||
* Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
|
||||
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';
|
||||
|
||||
/** The single fixed, browser-safe notice surfaced (with safe code
|
||||
* `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||
* negotiated send protocol is `unavailable` — the server never advertised a
|
||||
* usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime
|
||||
* in this slice), or the advertisement was rejected (wrong connection id, replay,
|
||||
* or an unknown protocol). It carries no dynamic detail. */
|
||||
const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||
|
||||
/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
|
||||
* Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
|
||||
* source is absent (not a function) or throws — the caller then refuses the turn
|
||||
* rather than falling back to any non-cryptographic source (Math.random, a
|
||||
* clock, or a counter would all be forgeable/collision-prone). Never throws. */
|
||||
function mintIdempotencyKey(): string | null {
|
||||
try {
|
||||
const c: unknown = globalThis.crypto;
|
||||
if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
|
||||
const key = (c.randomUUID as () => unknown)();
|
||||
return typeof key === 'string' && key.length > 0 ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
|
||||
* must be treated as a terminal startup failure: no conversation has ever been
|
||||
* established yet, and a send is still pending one. Once a conversation is
|
||||
@@ -236,6 +277,14 @@ export interface PendingApproval {
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the
|
||||
* server accepted this exact turn under its minted idempotency key and selection tuple. */
|
||||
export interface HarnessTurnReceipt {
|
||||
idempotencyKey: string;
|
||||
receiptId: string;
|
||||
selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ChatConnectionState {
|
||||
conversationId: string | null;
|
||||
/** True once a message has been sent while no conversation is active yet, so the
|
||||
@@ -268,6 +317,18 @@ export interface ChatConnectionState {
|
||||
approvalRequestPending: boolean;
|
||||
systemReload: SystemReloadPayload | null;
|
||||
error: string | null;
|
||||
/** How this connection is currently permitted to send, negotiated via the
|
||||
* server-to-client-only `chat:send-capability` advertisement. Starts and resets
|
||||
* to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped
|
||||
* connection has no usable protocol until the server (re-)advertises. This is
|
||||
* the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage`
|
||||
* actually reads; the ref is authoritative because an advertisement and a send
|
||||
* can occur in the same tick before React re-renders. */
|
||||
sendProtocol: ChatSendProtocol;
|
||||
/** Receipt from the most recently accepted harness `turn:ack`, or null before any
|
||||
* turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
|
||||
* untouched (a prior accepted receipt is not erased by a later rejection). */
|
||||
turnReceipt: HarnessTurnReceipt | null;
|
||||
messages: ChatTranscriptMessage[];
|
||||
/** Monotonically increasing counter used to mint transcript message ids —
|
||||
* never reset while retained messages remain, so ids stay unique across the
|
||||
@@ -308,7 +369,7 @@ export interface ChatConnectionState {
|
||||
}
|
||||
|
||||
export interface ChatConnectionActions {
|
||||
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
|
||||
abort: () => void;
|
||||
setThinking: (level: string) => void;
|
||||
executeCommand: (input: { command: string; args?: string }) => void;
|
||||
@@ -341,6 +402,8 @@ const initialState: ChatConnectionState = {
|
||||
approvalRequestPending: false,
|
||||
systemReload: null,
|
||||
error: null,
|
||||
sendProtocol: 'unavailable',
|
||||
turnReceipt: null,
|
||||
messages: [],
|
||||
messageSeq: 0,
|
||||
toolSeq: 0,
|
||||
@@ -361,7 +424,12 @@ type Action =
|
||||
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
|
||||
| { type: 'server/system:reload'; payload: SystemReloadPayload }
|
||||
| { type: 'server/error'; payload: ErrorPayload }
|
||||
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
|
||||
| { type: 'local/send'; content: string }
|
||||
| { type: 'local/capability'; protocol: ChatSendProtocol }
|
||||
| { type: 'local/reset-protocol' }
|
||||
| { type: 'local/send-unavailable' }
|
||||
| { type: 'local/turn-idempotency-unavailable' }
|
||||
| { type: 'local/approve-request'; command: string; args?: string }
|
||||
| { type: 'local/consume-approval' }
|
||||
| { type: 'local/approval-saturated' }
|
||||
@@ -778,6 +846,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/turn:ack': {
|
||||
// The harness runtime's turn acknowledgement. The success shape carries a
|
||||
// receipt id + minted idempotencyKey + echoed selection; the failure shape
|
||||
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
|
||||
// top-of-reducer guard already rejected a non-object payload).
|
||||
const record = action.payload as Record<string, unknown>;
|
||||
if (record.ok === false) {
|
||||
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
|
||||
// server `message`/`error`/`code`, which can carry adapter internals — and
|
||||
// does not disturb any previously accepted receipt.
|
||||
return { ...state, error: TURN_REJECTED_NOTICE };
|
||||
}
|
||||
const idempotencyKey = asString(record.idempotencyKey);
|
||||
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
|
||||
const receiptId = asString(record.receiptId) || asString(record.turnId);
|
||||
const selection = asHarnessSelection(record.selection);
|
||||
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
|
||||
// A malformed success frame is ignored outright rather than recorded as a
|
||||
// half-populated receipt.
|
||||
return state;
|
||||
}
|
||||
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
|
||||
}
|
||||
|
||||
case 'local/send': {
|
||||
const message: ChatTranscriptMessage = {
|
||||
// Sourced from the reducer-owned `messageSeq` counter — see the
|
||||
@@ -802,6 +894,39 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'local/capability': {
|
||||
// The FIRST valid `chat:send-capability` for this connection generation has
|
||||
// been accepted (connection-id gating + first-wins enforced in the handler);
|
||||
// record how this connection may now send. This is the reactive mirror of
|
||||
// the synchronous `protocolRef` the send path reads.
|
||||
return { ...state, sendProtocol: action.protocol };
|
||||
}
|
||||
|
||||
case 'local/reset-protocol': {
|
||||
// A (re)connect or disconnect voids any negotiated protocol: a fresh or
|
||||
// dropped connection has no usable send capability until the server
|
||||
// (re-)advertises. Reset to `unavailable` so no stale advertisement can
|
||||
// authorize a send across a connection boundary.
|
||||
if (state.sendProtocol === 'unavailable') return state;
|
||||
return { ...state, sendProtocol: 'unavailable' };
|
||||
}
|
||||
|
||||
case 'local/send-unavailable': {
|
||||
// A send was attempted while the negotiated protocol is `unavailable`
|
||||
// (never advertised / advertised unavailable / rejected advertisement).
|
||||
// Surface the single FIXED safe notice — nothing was emitted, minted,
|
||||
// appended, or locked.
|
||||
return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/turn-idempotency-unavailable': {
|
||||
// The idempotency-key mint failed closed (crypto.randomUUID absent or
|
||||
// throwing), so the turn was refused before emit. Surface a FIXED notice —
|
||||
// never the underlying thrown message, which can leak entropy-source
|
||||
// internals.
|
||||
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/disconnect': {
|
||||
// A transient socket disconnect must not leave the UI stuck waiting on
|
||||
// a turn/approval/send that will never resolve on this connection.
|
||||
@@ -882,6 +1007,20 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
approveLockRef.current = state.approvalRequestPending;
|
||||
}, [state.approvalRequestPending]);
|
||||
|
||||
// Synchronous, generation-bound send protocol. `state.sendProtocol` drives the
|
||||
// reactive UI, but reducer updates are batched/async — a `chat:send-capability`
|
||||
// advertisement and a `sendMessage` can land in the same tick before React
|
||||
// re-renders — so this ref is the source of truth the send path reads. Unlike
|
||||
// sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is
|
||||
// written directly by the socket lifecycle/capability handlers below, which
|
||||
// also dispatch the reducer mirror. It is NOT synchronized from state, because
|
||||
// its whole purpose is to be correct BEFORE the reducer has re-rendered.
|
||||
const protocolRef = useRef<ChatSendProtocol>('unavailable');
|
||||
// True once the first valid advertisement for the CURRENT connection generation
|
||||
// has been accepted; every later advertisement (a conflicting or replayed one)
|
||||
// is ignored until the next (re)connect/disconnect resets the generation.
|
||||
const protocolLockedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
@@ -913,7 +1052,45 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
const onError = (payload: ErrorPayload): void => {
|
||||
dispatch({ type: 'server/error', payload });
|
||||
};
|
||||
const onTurnAck = (payload: HarnessTurnAckPayload): void =>
|
||||
dispatch({ type: 'server/turn:ack', payload });
|
||||
|
||||
// Void the negotiated send protocol at every connection-lifecycle boundary.
|
||||
// A fresh or dropped connection has no usable capability until the server
|
||||
// (re-)advertises, so no advertisement bound to a prior connection may carry
|
||||
// across the boundary and authorize a send. Both write the synchronous ref
|
||||
// AND unlock first-wins, then dispatch the reducer mirror.
|
||||
const resetSendProtocol = (): void => {
|
||||
protocolRef.current = 'unavailable';
|
||||
protocolLockedRef.current = false;
|
||||
dispatch({ type: 'local/reset-protocol' });
|
||||
};
|
||||
const onConnect = (): void => {
|
||||
resetSendProtocol();
|
||||
};
|
||||
const onCapability = (payload: ChatSendCapabilityPayload): void => {
|
||||
// Server-to-client-only advertisement of how THIS connection may send.
|
||||
// Accept only the FIRST valid one per generation, and only when it names
|
||||
// this exact connection (`connectionId === socket.id`): a capability minted
|
||||
// for another or stale connection must never arm this one. The payload is
|
||||
// runtime-untrusted despite its compile-time type, so every field is
|
||||
// guard-checked and an unknown protocol is dropped (leaving `unavailable`).
|
||||
if (protocolLockedRef.current) return;
|
||||
if (!isRecord(payload)) return;
|
||||
const { protocol, connectionId } = payload as {
|
||||
protocol?: unknown;
|
||||
connectionId?: unknown;
|
||||
};
|
||||
if (typeof connectionId !== 'string' || connectionId !== socket.id) return;
|
||||
if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') {
|
||||
return;
|
||||
}
|
||||
protocolLockedRef.current = true;
|
||||
protocolRef.current = protocol;
|
||||
dispatch({ type: 'local/capability', protocol });
|
||||
};
|
||||
const onDisconnect = (): void => {
|
||||
resetSendProtocol();
|
||||
dispatch({ type: 'local/disconnect' });
|
||||
};
|
||||
|
||||
@@ -930,6 +1107,11 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
socket.on('command:approval', onCommandApproval);
|
||||
socket.on('system:reload', onSystemReload);
|
||||
socket.on('error', onError);
|
||||
socket.on('turn:ack', onTurnAck);
|
||||
// Registered BEFORE connect so the initial post-auth advertisement (and any
|
||||
// reconnect) can never race ahead of its listener.
|
||||
socket.on('connect', onConnect);
|
||||
socket.on('chat:send-capability', onCapability);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
|
||||
if (!socket.connected) {
|
||||
@@ -950,24 +1132,79 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
socket.off('command:approval', onCommandApproval);
|
||||
socket.off('system:reload', onSystemReload);
|
||||
socket.off('error', onError);
|
||||
socket.off('turn:ack', onTurnAck);
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('chat:send-capability', onCapability);
|
||||
socket.off('disconnect', onDisconnect);
|
||||
destroySocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const actions: ChatConnectionActions = {
|
||||
sendMessage: ({ content, provider, modelId }) => {
|
||||
if (sendLockRef.current || state.streaming || state.sending) return;
|
||||
sendLockRef.current = true;
|
||||
const socket = getSocket();
|
||||
if (!socket.connected) socket.connect();
|
||||
dispatch({ type: 'local/send', content });
|
||||
socket.emit('message', {
|
||||
conversationId: state.conversationId ?? undefined,
|
||||
content,
|
||||
provider,
|
||||
modelId,
|
||||
});
|
||||
sendMessage: ({ content, selection }) => {
|
||||
// Routing is PROTOCOL-driven, never inferred from conversation/selection/
|
||||
// provider/local mode: the server advertised, once per connection, exactly
|
||||
// how this connection may send, and that advertisement is authoritative.
|
||||
// The exhaustive switch maps each protocol to its ONE event; the send path
|
||||
// never reconnects the socket (a dropped connection has already reset the
|
||||
// protocol to `unavailable`, so no emit branch is reachable while offline).
|
||||
switch (protocolRef.current) {
|
||||
case 'legacy-message': {
|
||||
// Embedded/legacy runtime: EVERY browser turn — the first (which
|
||||
// creates the conversation) and every later one — is the `message`
|
||||
// event. provider/model are sourced ONLY from the confirmed persisted
|
||||
// selection tuple, never from separate flat caller inputs.
|
||||
if (sendLockRef.current || state.streaming || state.sending) return false;
|
||||
sendLockRef.current = true;
|
||||
const socket = getSocket();
|
||||
dispatch({ type: 'local/send', content });
|
||||
socket.emit('message', {
|
||||
conversationId: state.conversationId ?? undefined,
|
||||
content,
|
||||
provider: selection?.providerId,
|
||||
modelId: selection?.modelId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'turn-send': {
|
||||
// Pi turn-runtime: the exclusive `turn:send` contract. Requires an
|
||||
// already-established conversation AND a confirmed persisted selection
|
||||
// tuple; it is lock-independent (no send lock, no optimistic append),
|
||||
// and mints exactly one idempotency key per accepted turn, failing the
|
||||
// turn closed if the mint fails. A premature send (no conversation yet,
|
||||
// or no selection) is refused with no emit and no notice.
|
||||
if (selection == null || state.conversationId === null) return false;
|
||||
const idempotencyKey = mintIdempotencyKey();
|
||||
if (idempotencyKey === null) {
|
||||
dispatch({ type: 'local/turn-idempotency-unavailable' });
|
||||
return false;
|
||||
}
|
||||
const socket = getSocket();
|
||||
socket.emit('turn:send', {
|
||||
conversationId: state.conversationId,
|
||||
content,
|
||||
selection,
|
||||
idempotencyKey,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'unavailable': {
|
||||
// No usable protocol negotiated for this connection: refuse without
|
||||
// emitting, minting, appending, or acquiring the lock, and surface the
|
||||
// one fixed safe notice (code `send_protocol_unavailable`).
|
||||
dispatch({ type: 'local/send-unavailable' });
|
||||
return false;
|
||||
}
|
||||
default: {
|
||||
// Exhaustiveness guard: every ChatSendProtocol member is handled above.
|
||||
// An unknown value can never arm a send — refuse exactly as
|
||||
// `unavailable` rather than falling through to any emit.
|
||||
const _exhaustive: never = protocolRef.current;
|
||||
void _exhaustive;
|
||||
dispatch({ type: 'local/send-unavailable' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
abort: () => {
|
||||
|
||||
@@ -226,7 +226,10 @@ describe('useHarnessSelection', () => {
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
|
||||
// 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 () => {
|
||||
@@ -386,7 +389,8 @@ describe('useHarnessSelection', () => {
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
});
|
||||
expect(value().projection).toEqual({ provider: '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 () => {
|
||||
@@ -420,7 +424,8 @@ describe('useHarnessSelection', () => {
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
|
||||
// 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 () => {
|
||||
|
||||
@@ -42,10 +42,6 @@ export interface HarnessSelectionValue {
|
||||
* resolves the composite option identity to the real entry and passes both
|
||||
* ids, so a bare model id is never combined with ambient provider state. */
|
||||
selectModel: (providerId: string, modelId: string) => void;
|
||||
/** The compatibility `{provider, modelId}` projection for the legacy socket
|
||||
* send path — derived ONLY from the validated persisted tuple, never from any
|
||||
* free-text or unpersisted draft. Empty when nothing is sendable. */
|
||||
projection: { provider?: string; modelId?: string };
|
||||
}
|
||||
|
||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||
@@ -193,9 +189,6 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
||||
!catalogUnavailable &&
|
||||
tuplesEqual(draft, persistedSelection) &&
|
||||
isAvailableInCatalog(persistedSelection, catalog);
|
||||
const projection: { provider?: string; modelId?: string } = canSend
|
||||
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
|
||||
: {};
|
||||
|
||||
return {
|
||||
harnesses,
|
||||
@@ -211,6 +204,5 @@ export function useHarnessSelection(): HarnessSelectionValue {
|
||||
selectHarness,
|
||||
selectProvider,
|
||||
selectModel,
|
||||
projection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,58 @@ async function flushAsync(times = 5): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -137,6 +189,12 @@ beforeEach(async () => {
|
||||
// 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 () => {
|
||||
@@ -159,6 +217,11 @@ async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
||||
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', () => {
|
||||
@@ -571,6 +634,156 @@ describe('ChatPage', () => {
|
||||
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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user