refactor(chat): route browser chat through one runtime

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
2026-08-12 14:43:59 -05:00
co-authored by Claude Opus 4.8
parent 6a8ce66702
commit bd69eca555
29 changed files with 4417 additions and 662 deletions
+6
View File
@@ -15,6 +15,9 @@ import type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
@@ -42,6 +45,9 @@ export type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
+22 -4
View File
@@ -1,8 +1,14 @@
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;
provider?: string;
modelId?: string;
selection: HarnessSelection;
}) => boolean;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
@@ -44,11 +50,23 @@ 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 BOTH the nested selection tuple (the turn-runtime contract) and the
// flat provider/modelId (the legacy first-send that creates the conversation)
// — both derived from the same validated persisted tuple. The hook decides
// which path applies from whether a conversation is already established.
const selection = harness.persistedSelection;
const ok = onSend({
content: trimmed,
selection,
provider: selection.providerId,
modelId: selection.modelId,
});
// 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 {
@@ -21,6 +21,7 @@ vi.mock('@/lib/socket', () => ({
destroySocket: destroySocketMock,
}));
import type { HarnessSelection } from '@mosaicstack/types';
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
let fake: ReturnType<typeof createFakeChatSocket>;
@@ -33,6 +34,111 @@ 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;
}
/**
* 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,
@@ -373,6 +479,371 @@ 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';
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';
// 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;
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' });
+120 -3
View File
@@ -12,6 +12,7 @@ import {
import {
asConversationId,
asFiniteNumber,
asHarnessSelection,
asString,
asStringArray,
isRecord,
@@ -25,6 +26,8 @@ import type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
MessageAckPayload,
SessionInfoPayload,
SessionUsagePayload,
@@ -130,6 +133,34 @@ 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.';
/** 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 +267,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 +307,10 @@ export interface ChatConnectionState {
approvalRequestPending: boolean;
systemReload: SystemReloadPayload | null;
error: string | null;
/** 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 +351,12 @@ export interface ChatConnectionState {
}
export interface ChatConnectionActions {
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
sendMessage: (input: {
content: string;
provider?: string;
modelId?: string;
selection?: HarnessSelection;
}) => boolean;
abort: () => void;
setThinking: (level: string) => void;
executeCommand: (input: { command: string; args?: string }) => void;
@@ -341,6 +389,7 @@ const initialState: ChatConnectionState = {
approvalRequestPending: false,
systemReload: null,
error: null,
turnReceipt: null,
messages: [],
messageSeq: 0,
toolSeq: 0,
@@ -361,7 +410,9 @@ 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/turn-idempotency-unavailable' }
| { type: 'local/approve-request'; command: string; args?: string }
| { type: 'local/consume-approval' }
| { type: 'local/approval-saturated' }
@@ -778,6 +829,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 +877,14 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
};
}
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.
@@ -913,6 +996,8 @@ 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 });
const onDisconnect = (): void => {
dispatch({ type: 'local/disconnect' });
};
@@ -930,6 +1015,7 @@ export function useChatConnection(): ChatConnectionValue {
socket.on('command:approval', onCommandApproval);
socket.on('system:reload', onSystemReload);
socket.on('error', onError);
socket.on('turn:ack', onTurnAck);
socket.on('disconnect', onDisconnect);
if (!socket.connected) {
@@ -950,14 +1036,44 @@ 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('disconnect', onDisconnect);
destroySocket();
};
}, []);
const actions: ChatConnectionActions = {
sendMessage: ({ content, provider, modelId }) => {
if (sendLockRef.current || state.streaming || state.sending) return;
sendMessage: ({ content, provider, modelId, selection }) => {
// Turn-runtime path: a selection tuple against an already-established
// conversation routes through the exclusive `turn:send` contract. It is
// lock-independent by design — it does not engage the send lock, does not
// dispatch `local/send` (no optimistic append), and mints exactly one
// idempotency key per accepted turn. A failed mint fails the turn closed.
if (selection != null && state.conversationId !== null) {
const idempotencyKey = mintIdempotencyKey();
if (idempotencyKey === null) {
dispatch({ type: 'local/turn-idempotency-unavailable' });
return false;
}
const socket = getSocket();
if (!socket.connected) socket.connect();
socket.emit('turn:send', {
conversationId: state.conversationId,
content,
selection,
idempotencyKey,
});
return true;
}
// A selection tuple with no active conversation and no flat provider/model
// is a bare harness call that cannot create a conversation — refuse it,
// emitting nothing and appending nothing.
if (selection != null && provider === undefined && modelId === undefined) {
return false;
}
// Legacy `message` path (first send that creates the conversation, and every
// pre-turn-runtime send) — unchanged behavior, now reporting acceptance.
if (sendLockRef.current || state.streaming || state.sending) return false;
sendLockRef.current = true;
const socket = getSocket();
if (!socket.connected) socket.connect();
@@ -968,6 +1084,7 @@ export function useChatConnection(): ChatConnectionValue {
provider,
modelId,
});
return true;
},
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,
};
}
+173
View File
@@ -108,6 +108,33 @@ 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');
}
};
}
let fake: ReturnType<typeof createFakeChatSocket>;
let root: Root | null;
let container: HTMLElement;
@@ -571,6 +598,152 @@ 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 () => {
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 () => {
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 () => {
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 () => {
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));