Compare commits

...
Author SHA1 Message Date
shaggy (mosaic-dev box)andClaude Opus 4.8 b21c84f231 refactor(chat): route browser chat through one runtime
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-12 14:19:58 -05:00
jason.woltjeandClaude Opus 4.8 33ca4b2a6a 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
2026-08-12 13:47:29 -05:00
jason.woltjeandClaude Opus 4.8 694f1a4199 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
2026-08-12 12:05:05 -05:00
jason.woltjeandClaude Opus 4.8 472dcee7ed 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
2026-08-12 11:07:20 -05:00
shaggy (mosaic-dev box)andClaude Opus 4.8 633acd2d2a 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
2026-08-12 07:56:21 -05:00
mos-dt-0 6a8ce66702 Merge pull request 'feat(lease): verified lease-remediation stack (rebased onto next) — promotion trigger + promote CLI + carve-out + TTL' (#1109) from feat/lease-promotion-and-harness-isolation into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 03:07:32 +00:00
jason.woltjeandMos 9cd9409089 P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog (#1170)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-12 02:50:18 +00:00
38 changed files with 8706 additions and 684 deletions
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
},
];
brain = createMockBrain({ searchResults });
controller = new ConversationsController(brain as never);
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
});
it('returns matching messages for a valid search query', async () => {
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
it('addMessage returns the persisted message', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const result = await controller.addMessage(
CONV_ID,
@@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis
};
}
/**
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
* those paths run entirely through the command-authorization, runtime-provider and durable-session
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
* stays a GREEN cross-surface control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the Discord control path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
},
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
@@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
);
const gateway = new ChatGateway(
{} as never,
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
@@ -1,6 +1,8 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
@@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
@@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession {
};
}
/**
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
* USER_A's conversation id) is never granted the session. Because every method exists and no method
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
*/
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
@@ -87,7 +110,7 @@ function makeScopedAgentService() {
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
@@ -96,6 +119,201 @@ function makeScopedAgentService() {
};
}
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
/**
* A structurally-complete harness conversation service that throws if any method is invoked.
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
*/
const failIfUsedConversationService = {
attach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
detach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
send: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
subscribeFrom: async function* () {
throw new Error('harness conversation service must not be reached on the legacy path');
},
} as unknown as HarnessConversationService;
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
/**
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
*/
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(failIfUsedConversationService);
const router = new ChatRuntimeRouter(
new HarnessRegistry(),
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
embedded,
harness,
'legacy',
);
router.onModuleInit();
return router;
}
/**
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
*/
const FORBIDDEN_AGENT_OPS = [
'getSession',
'createSession',
'onEvent',
'addChannel',
'prompt',
'setThinking',
'abort',
] as const;
/**
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
*
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
*/
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
return new Proxy(target, {
get(t, prop) {
const value = Reflect.get(t, prop);
if (typeof value === 'function' && typeof prop === 'string') {
return (...args: unknown[]) => {
calls.push(prop);
return (value as (...a: unknown[]) => unknown).apply(t, args);
};
}
return value;
},
}) as ChatRuntimeRouter;
}
/**
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
*
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
* resolves whichever its constructor declares:
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
* router (and its embedded fake) is never reached.
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
*/
function buildRestModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
return (
Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: directAgentService },
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
})
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile()
);
}
function buildGatewayModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
const brain = {
conversations: {
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
// property has its own dedicated coverage.
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
},
};
return Test.createTestingModule({
providers: [
ChatGateway,
{ provide: AgentService, useValue: directAgentService },
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
{ provide: BRAIN, useValue: brain },
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
{
provide: RoutingEngineService,
useValue: {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
},
},
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
}).compile();
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
@@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
function makeSocket() {
return {
id: 'socket-b',
@@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () =>
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'runtime_unsupported' }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,920 @@
import 'reflect-metadata';
import { Global, Module } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { ChatModule } from './chat.module.js';
import { ChatGateway } from './chat.gateway.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import {
ChatRuntimeUnavailableError,
ownConversation,
type ChatRuntime,
type ChatRuntimeMode,
type LegacyEmbeddedChatPort,
type LegacyRuntimeStream,
type LegacySessionPresentation,
type LegacySocketTurnLease,
type OwnedConversationContext,
} from './chat-runtime.js';
import { AppModule } from '../app.module.js';
import { ProviderService } from '../agent/provider.service.js';
/**
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
* unimplemented stub, so every behavioural assertion below fails until Step Three.
*/
const embedded: ChatRuntime = { kind: 'embedded' };
const harness: ChatRuntime = { kind: 'harness' };
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
function buildRouter(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
): ChatRuntimeRouter {
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
}
/**
* Tear down a module that was deliberately driven to a fail-closed init.
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
* startup error, this time into teardown. Each caller here has already captured and asserted that
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
*/
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
await moduleRef.close().catch((err: unknown) => {
if (err instanceof ChatRuntimeUnavailableError) return;
throw err;
});
}
describe('ChatRuntimeRouter', () => {
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
});
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
// Empty registry + unavailable service: legacy must ignore both and still start.
const router = buildRouter('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
});
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
// A failed pi-rpc init must not silently expose the embedded runtime.
expect(() => router.active).toThrow();
let leaked: ChatRuntime | undefined;
try {
leaked = router.active;
} catch {
leaked = undefined;
}
expect(leaked).not.toBe(embedded);
});
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw');
} catch (err) {
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
}
});
});
/**
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
*
* The unit suite above constructs the router but never invokes a legacy port operation, so the
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
*
* For each operation:
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
* result AND that the embedded stub was touched zero times (no effects);
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
* not vacuously true because the stub could never be called.
*
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
*/
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
const RUNTIME_UNSUPPORTED = {
ok: false,
code: 'runtime_unsupported',
retryable: false,
} as const;
const PRESENTATION: LegacySessionPresentation = {
provider: 'embedded-provider',
modelId: 'embedded-model',
thinkingLevel: 'low',
availableThinkingLevels: ['low', 'high'],
};
const stream: LegacyRuntimeStream = {
channelId: 'websocket:test-socket',
onEvent: () => {},
};
const ctx = (): OwnedConversationContext =>
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
/**
* Per-operation invocation counters with declared keys (not an index signature) so each
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
*/
type LegacyPortCallCounts = {
completeLegacyRestTurn: number;
prepareLegacySocketTurn: number;
setLegacyThinking: number;
abortLegacyTurn: number;
applyLegacyModelOverride: number;
readLegacySessionPresentation: number;
dispatchVerifiedDiscordIngress: number;
};
/**
* An embedded port that records every invocation and returns a distinguishable `ok:true`
* value per operation. If a router op reaches it (its guard removed), both the recorded call
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
*/
function recordingEmbeddedPort(): {
port: ChatRuntime & LegacyEmbeddedChatPort;
calls: LegacyPortCallCounts;
} {
const calls: LegacyPortCallCounts = {
completeLegacyRestTurn: 0,
prepareLegacySocketTurn: 0,
setLegacyThinking: 0,
abortLegacyTurn: 0,
applyLegacyModelOverride: 0,
readLegacySessionPresentation: 0,
dispatchVerifiedDiscordIngress: 0,
};
const lease: LegacySocketTurnLease = {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
};
const port: ChatRuntime & LegacyEmbeddedChatPort = {
kind: 'embedded',
completeLegacyRestTurn: () => {
calls.completeLegacyRestTurn += 1;
return Promise.resolve({
ok: true,
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
});
},
prepareLegacySocketTurn: () => {
calls.prepareLegacySocketTurn += 1;
return Promise.resolve({ ok: true, value: lease });
},
setLegacyThinking: () => {
calls.setLegacyThinking += 1;
return { ok: true, value: PRESENTATION };
},
abortLegacyTurn: () => {
calls.abortLegacyTurn += 1;
return Promise.resolve({ ok: true, value: undefined });
},
applyLegacyModelOverride: () => {
calls.applyLegacyModelOverride += 1;
return { ok: true, value: PRESENTATION };
},
readLegacySessionPresentation: () => {
calls.readLegacySessionPresentation += 1;
return { ok: true, value: PRESENTATION };
},
dispatchVerifiedDiscordIngress: () => {
calls.dispatchVerifiedDiscordIngress += 1;
return Promise.resolve({
ok: true,
value: {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
},
});
},
};
return { port, calls };
}
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
port,
harness,
'pi-rpc',
);
}
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith([]),
boundConversationService,
port,
harness,
'legacy',
);
}
// completeLegacyRestTurn ---------------------------------------------------
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.completeLegacyRestTurn).toBe(0);
});
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result.ok).toBe(true);
expect(calls.completeLegacyRestTurn).toBe(1);
});
// prepareLegacySocketTurn --------------------------------------------------
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.prepareLegacySocketTurn).toBe(0);
});
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result.ok).toBe(true);
expect(calls.prepareLegacySocketTurn).toBe(1);
});
// setLegacyThinking (sync) -------------------------------------------------
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.setLegacyThinking).toBe(0);
});
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
expect(result.ok).toBe(true);
expect(calls.setLegacyThinking).toBe(1);
});
// abortLegacyTurn ----------------------------------------------------------
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).abortLegacyTurn(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.abortLegacyTurn).toBe(0);
});
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).abortLegacyTurn(ctx());
expect(result.ok).toBe(true);
expect(calls.abortLegacyTurn).toBe(1);
});
// applyLegacyModelOverride (sync) ------------------------------------------
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.applyLegacyModelOverride).toBe(0);
});
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result.ok).toBe(true);
expect(calls.applyLegacyModelOverride).toBe(1);
});
// readLegacySessionPresentation (sync) -------------------------------------
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).readLegacySessionPresentation(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.readLegacySessionPresentation).toBe(0);
});
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
expect(result.ok).toBe(true);
expect(calls.readLegacySessionPresentation).toBe(1);
});
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
const { port, calls } = recordingEmbeddedPort();
const discordCtx = ctx() as unknown as Parameters<
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
>[0];
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
expect(result.ok).toBe(true);
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
});
});
/**
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
*
* The unit suite above constructs the router directly. This group drives the SAME contract
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
* `onModuleInit` throws a generic Error, so:
* - readiness cases fail because the graph never comes up (init rejects), and
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
* "throws anything" cannot mask these greens.
* The router is NOT wired into a production module yet, so it is provided here via a factory
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
*/
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
async function bootRouterGraph(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
) {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
providers: [
{
provide: ChatRuntimeRouter,
useFactory: (registry: HarnessRegistry) =>
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
inject: [HARNESS_REGISTRY],
},
],
})
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
// group 1 asserts against the genuine production HarnessRegistry.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Resolve the production registry singleton and register the requested adapters ON IT, so
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
for (const id of opts.adapters) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return moduleRef;
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
const moduleRef = await bootRouterGraph('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
// Defense-in-depth: the production module wires the genuine registry, empty by default —
// guards against a test-double registry silently satisfying the readiness check.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
} finally {
await moduleRef.close();
}
});
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
});
/**
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
*
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
*
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
* of the two covers "the router is wired through ChatModule to the real registry" without the
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
*/
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
const resolveImport = (imp: unknown): unknown =>
imp &&
typeof imp === 'object' &&
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
? (imp as { forwardRef: () => unknown }).forwardRef()
: imp;
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
const providerToken = (provider: unknown): unknown =>
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
});
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
expect(imports.map(resolveImport)).toContain(HarnessModule);
});
});
/**
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
*
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
* with an inert value;
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
* - the HTTP-only `AuthGuard` is stubbed.
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
*
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
* conversation-service token (defaulting to the unavailable sentinel).
*/
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
@Module({})
class EmptyCommandsModule {}
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
@Global()
@Module({
providers: [{ provide: AgentService, useValue: {} }],
exports: [AgentService],
})
class LegacyControllerDepsModule {}
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
afterEach(() => {
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
});
/**
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
* genuine production env contract before providers instantiate. The optional overrides replace
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
* the graph — which is exactly why the router-retrieval assertions go red).
*/
async function bootChatModule(
mode: ChatRuntimeMode,
overrides: {
registryAdapters?: readonly string[];
conversationService?: HarnessConversationServiceBinding;
} = {},
): Promise<TestingModule> {
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
else delete process.env['CHAT_HARNESS_RUNTIME'];
let builder = Test.createTestingModule({
imports: [LegacyControllerDepsModule, ChatModule],
})
.overrideModule(CommandsModule)
.useModule(EmptyCommandsModule)
.overrideProvider(ChatGateway)
.useValue({})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true });
if (overrides.registryAdapters) {
builder = builder
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWith(overrides.registryAdapters));
}
if (overrides.conversationService !== undefined) {
builder = builder
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
.useValue(overrides.conversationService);
}
return builder.compile();
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: TestingModule): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
const moduleRef = await bootChatModule('legacy');
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
const service = moduleRef.get<HarnessConversationServiceBinding>(
HARNESS_CONVERSATION_SERVICE,
{
strict: false,
},
);
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
} finally {
await moduleRef.close();
}
});
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc');
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
});
/**
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
*
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
* the disk/network leaves:
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
* system-rule count — the fake reports rules already present so the seed insert is skipped),
* opening no real database;
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
* storage/auth/log backend is contacted.
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
*
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
* effect.
*/
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
const fakeStorageAdapter = {
name: 'fake',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
};
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({
getAvailable: () => [],
getAll: () => [],
find: () => undefined,
}),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
const fakeBrain = { conversations: {}, agents: {} };
const BOOT_TIMEOUT_MS = 120_000;
let moduleRef: TestingModule;
let envSnapshot: Record<string, string | undefined>;
beforeAll(async () => {
envSnapshot = { ...process.env };
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
delete process.env['DATABASE_URL'];
delete process.env['DISCORD_BOT_TOKEN'];
delete process.env['TELEGRAM_BOT_TOKEN'];
delete process.env['MCP_SERVERS'];
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
process.env['MOSAIC_STORAGE_TIER'] = 'local';
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
.overrideProvider('DB_HANDLE')
.useValue(fakeDbHandle)
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue(fakeStorageAdapter)
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue(fakeBrain)
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
await moduleRef.init();
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
if (moduleRef) await moduleRef.close();
for (const key of Object.keys(process.env)) {
if (!(key in envSnapshot)) delete process.env[key];
}
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
const gateway = moduleRef.get(ChatGateway, { strict: false });
expect(typeof gateway.broadcastReload).toBe('function');
expect(typeof gateway.getModelOverride).toBe('function');
expect(typeof gateway.setModelOverride).toBe('function');
expect(typeof gateway.broadcastSessionInfo).toBe('function');
});
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
});
});
@@ -0,0 +1,173 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
isHarnessConversationServiceAvailable,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type {
ChatRuntime,
ChatRuntimeMode,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeResult,
LegacyRuntimeStream,
LegacySessionPresentation,
LegacySocketTurnLease,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
} from './chat-runtime.js';
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
const RUNTIME_UNSUPPORTED = {
ok: false as const,
code: 'runtime_unsupported' as const,
retryable: false as const,
};
/**
* Resolves the one live {@link ChatRuntime} for this process and enforces the
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
*/
@Injectable()
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
private readonly mode: ChatRuntimeMode;
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
private resolved: ChatRuntime | undefined;
constructor(
private readonly harnessRegistry: HarnessRegistry,
private readonly conversationService: HarnessConversationServiceBinding,
private readonly embedded: ChatRuntime,
private readonly harness: ChatRuntime,
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
) {
this.mode = mode;
}
onModuleInit(): void {
if (this.mode === 'legacy') {
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
this.resolved = this.embedded;
return;
}
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
if (!this.harnessRegistry.has('pi')) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('adapter_unavailable');
}
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
}
this.resolved = this.harness;
}
get active(): ChatRuntime {
if (this.resolved === undefined) {
// Reached only if init has not run or failed closed; never expose a runtime here.
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
}
return this.resolved;
}
/**
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
*/
get runtimeMode(): ChatRuntimeMode {
return this.mode;
}
/**
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
* verified-Discord op in both modes), where the injected runtime is always a real
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
* but never invokes a port op, so this narrowing is never exercised against the stub.
*/
private get embeddedPort(): LegacyEmbeddedChatPort {
return this.embedded as unknown as LegacyEmbeddedChatPort;
}
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.completeLegacyRestTurn(context, input);
}
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
}
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.setLegacyThinking(context, level);
}
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.abortLegacyTurn(context);
}
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
}
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.readLegacySessionPresentation(context);
}
/**
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
* never reaches the harness or routing-engine selection. It is reached only through a
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
*/
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
}
}
+273
View File
@@ -0,0 +1,273 @@
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
/**
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
*
* Exactly one runtime is live per process. There is no union that lets a
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
*/
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
export type ChatRuntimeKind = 'embedded' | 'harness';
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
export interface ChatRuntime {
readonly kind: ChatRuntimeKind;
}
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
export type ChatRuntimeUnavailableReason =
| 'adapter_unavailable'
| 'conversation_service_unavailable';
/**
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
*/
export class ChatRuntimeUnavailableError extends Error {
readonly code = 'runtime_unsupported' as const;
readonly reason: ChatRuntimeUnavailableReason;
constructor(reason: ChatRuntimeUnavailableReason) {
super(
reason === 'adapter_unavailable'
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
);
this.name = 'ChatRuntimeUnavailableError';
this.reason = reason;
}
}
/**
* Resolves the process-wide chat runtime mode from the environment. Anything other
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
*/
export function resolveChatRuntimeMode(
env: Record<string, string | undefined> = process.env,
): ChatRuntimeMode {
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
}
// ---------------------------------------------------------------------------
// Transitional embedded chat port (Task Five).
//
// The legacy embedded browser behaviour is moved behind this exact interface so
// neither the controller nor the gateway retains AgentService, RoutingEngine,
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
// operations and returns `runtime_unsupported` before touching Embedded for legacy
// browser operations when the mode is `pi-rpc`.
//
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
// transport/trust capabilities — there is deliberately no generic
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
// ---------------------------------------------------------------------------
/**
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
* DTOs are never structurally assignable to it. The factory that mints one may be called
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
*/
declare const ownedConversationContextBrand: unique symbol;
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
export interface OwnedConversationContext {
readonly [ownedConversationContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
}
/**
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
* failures are total results and never throw.
*/
export type LegacyRuntimeFailure =
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
| {
readonly ok: false;
readonly code: 'thinking_level_invalid';
readonly retryable: false;
readonly availableThinkingLevels: readonly string[];
}
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
export type LegacyRuntimeResult<T> =
| { readonly ok: true; readonly value: T }
| LegacyRuntimeFailure;
/** User-facing session projection. Carries no session object, handle, or credential path. */
export interface LegacySessionPresentation {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly availableThinkingLevels: readonly string[];
readonly agentName?: string;
readonly routingDecision?: RoutingDecisionInfo;
}
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
export interface LegacyUsage {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly tokens: Readonly<{
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
}>;
readonly cost: number;
readonly context: Readonly<{ percent: number | null; window: number }>;
}
/**
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
*/
export type LegacyRuntimeEvent =
| { readonly type: 'started' }
| { readonly type: 'text_delta'; readonly text: string }
| { readonly type: 'thinking_delta'; readonly text: string }
| {
readonly type: 'tool_started';
readonly toolCallId: string;
readonly toolName: string;
}
| {
readonly type: 'tool_finished';
readonly toolCallId: string;
readonly toolName: string;
readonly isError: boolean;
}
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
export interface LegacyBrowserMessagePayload {
readonly content: string;
readonly provider?: string;
readonly modelId?: string;
readonly agentId?: string;
readonly attachments?: readonly ChannelAttachmentDto[];
}
/** A prepared-but-not-yet-dispatched legacy socket turn. */
export interface LegacySocketTurnLease {
readonly presentation: LegacySessionPresentation;
/**
* Atomically one-shot and scope-rechecking. A second call returns
* `turn_already_dispatched` and performs zero prompt/tool effects.
*/
dispatch(): Promise<LegacyRuntimeResult<void>>;
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
dispose(): Promise<void>;
}
/**
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
* forced-scope, and attachment-normalization checks.
*/
declare const verifiedDiscordIngressContextBrand: unique symbol;
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
export interface VerifiedDiscordIngressContext {
readonly [verifiedDiscordIngressContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
readonly content: string;
readonly attachments?: readonly ChannelAttachmentDto[];
readonly correlationId: string;
readonly discordMessageId: string;
readonly discordUserId: string;
}
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
export interface VerifiedDiscordTurnLease {
readonly presentation: LegacySessionPresentation;
dispatch(): Promise<LegacyRuntimeResult<void>>;
dispose(): Promise<void>;
}
/** Server-owned egress projection the runtime pushes normalized events into. */
export interface LegacyRuntimeStream {
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
readonly channelId: string;
onEvent(event: LegacyRuntimeEvent): void;
}
/**
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
* browser operations under `pi-rpc`.
*/
export interface LegacyEmbeddedChatPort {
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
>;
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation>;
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
}
/**
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
*/
export function ownConversation(
conversationId: string,
scope: Readonly<{ userId: string; tenantId: string }>,
): OwnedConversationContext {
return { conversationId, scope } as unknown as OwnedConversationContext;
}
/**
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
* configured-agent, forced-scope, attachment normalization) before calling this.
*/
export function verifyDiscordIngress(
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
): VerifiedDiscordIngressContext {
return { ...fields } as unknown as VerifiedDiscordIngressContext;
}
+32 -63
View File
@@ -3,21 +3,20 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { ownConversation } from './chat-runtime.js';
import type { LegacyRuntimeFailure } from './chat-runtime.js';
interface ChatResponse {
conversationId: string;
@@ -29,7 +28,7 @@ interface ChatResponse {
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
constructor(private readonly runtime: ChatRuntimeRouter) {}
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@@ -40,68 +39,38 @@ export class ChatController {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
let responseText = '';
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
const result = await this.runtime.completeLegacyRestTurn(
ownConversation(conversationId, scope),
{ content: body.content },
);
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
}
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
if (result.ok) {
return { conversationId, text: result.value.text };
}
return { conversationId, text: responseText };
throw this.toHttpException(result, conversationId);
}
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
switch (failure.code) {
case 'conversation_unavailable':
return new NotFoundException('Session not found');
case 'request_invalid':
case 'thinking_level_invalid':
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
case 'timeout':
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
case 'runtime_unsupported':
case 'runtime_unavailable':
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
default:
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
+63 -1
View File
@@ -1,5 +1,14 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@@ -37,3 +46,56 @@ export class ChatSocketMessageDto {
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}
/**
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
*
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
* fails `@IsString` (undefined is not a string) rather than silently passing.
*/
export class HarnessTurnSelectionDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
harnessId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
providerId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
modelId!: string;
}
/**
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
*
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
*/
export class HarnessTurnSendDto {
@IsUUID()
conversationId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
@MaxLength(10_000)
content!: string;
@IsObject()
@ValidateNested()
@Type(() => HarnessTurnSelectionDto)
selection!: HarnessTurnSelectionDto;
@IsUUID('4')
idempotencyKey!: string;
}
@@ -8,12 +8,31 @@ const payload: SlashCommandPayload = {
approvalId: 'approval-1',
};
/**
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
* loudly instead of silently passing. Because execute/approval run entirely through the
* command executor dependency and never resolve a chat runtime, this fixture is never
* triggered and the ingress stays a GREEN control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the command approval path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the command approval path');
},
};
}
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
{} as never,
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
});
});
});
/**
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
* fall-through to embedded execution. Only runtime-independent audited system commands
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
*/
function buildPiRpcGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
const piRpcRouter = {
runtimeMode: 'pi-rpc' as const,
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
},
};
return new ChatGateway(
piRpcRouter as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
it.each(['model', 'agent', 'gc'])(
'fails /%s closed before the executor under pi-rpc (execute never called)',
async (command): Promise<void> => {
const commandExecutor = {
execute: vi
.fn()
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command,
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
expect(client.emit).toHaveBeenCalledWith('command:result', {
command,
conversationId: 'conversation-1',
success: false,
message: UNSUPPORTED,
});
},
);
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
const commandExecutor = {
execute: vi.fn().mockResolvedValue(reloadResult),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command: 'reload',
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
expect(commandExecutor.execute).toHaveBeenCalledWith(
{ command: 'reload', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
});
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith(
'command:approval',
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
);
});
});
Binary file not shown.
File diff suppressed because it is too large Load Diff
+50 -3
View File
@@ -1,12 +1,59 @@
import { forwardRef, Module } from '@nestjs/common';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type { HarnessConversationService } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
* authority: the controller and gateway inject only the router, never `AgentService`,
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
*
* The router and the harness runtime are constructed through factories because their
* dependencies are interface/union types with no runtime injection token (the registry and
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
* runtime injects the class-typed `AgentService` and is provided directly.
*/
@Module({
imports: [forwardRef(() => CommandsModule)],
imports: [forwardRef(() => CommandsModule), HarnessModule],
controllers: [ChatController],
providers: [ChatGateway],
exports: [ChatGateway],
providers: [
ChatGateway,
EmbeddedChatRuntime,
{
provide: HarnessChatRuntime,
useFactory: (conversationService: HarnessConversationServiceBinding) =>
new HarnessChatRuntime(conversationService as HarnessConversationService),
inject: [HARNESS_CONVERSATION_SERVICE],
},
{
provide: ChatRuntimeRouter,
useFactory: (
registry: HarnessRegistry,
conversationService: HarnessConversationServiceBinding,
embedded: EmbeddedChatRuntime,
harness: HarnessChatRuntime,
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
inject: [
HARNESS_REGISTRY,
HARNESS_CONVERSATION_SERVICE,
EmbeddedChatRuntime,
HarnessChatRuntime,
],
},
],
exports: [ChatGateway, ChatRuntimeRouter],
})
export class ChatModule {}
@@ -0,0 +1,532 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import type {
ChatRuntime,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeEvent,
LegacyRuntimeResult,
LegacySessionPresentation,
LegacySocketTurnLease,
LegacyUsage,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
LegacyRuntimeStream,
} from './chat-runtime.js';
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
const REST_TURN_TIMEOUT_MS = 120_000;
/**
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
*
* It owns the embedded in-process execution path — the `AgentService` stack that the
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
* collapses to `conversation_unavailable` and never throws out of the port.
*/
@Injectable()
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
readonly kind = 'embedded' as const;
private readonly logger = new Logger(EmbeddedChatRuntime.name);
constructor(readonly agentService: AgentService) {}
// -------------------------------------------------------------------------
// Legacy REST completion (op A)
// -------------------------------------------------------------------------
async completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {});
if (!resolved.ok) return resolved;
let responseText = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let detach: (() => void) | undefined;
let disposed = false;
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
// would return while leaving the listener attached (free to consume a later turn's events) and
// the 120s timer live (its rejection later going unobserved).
const dispose = (): void => {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeout(timer);
detach?.();
};
const done = new Promise<void>((resolve, reject) => {
timer = setTimeout(() => {
dispose();
reject(new Error('Agent response timed out'));
}, REST_TURN_TIMEOUT_MS);
detach = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
dispose();
resolve();
}
},
scope,
);
});
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
const prompting = this.agentService.prompt(conversationId, input.content, scope);
try {
await Promise.all([prompting, done]);
} catch (err) {
dispose();
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
return { ok: false, code: 'timeout', retryable: true };
}
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
return { ok: false, code: 'operation_failed', retryable: false };
}
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
return { ok: true, value: { text: responseText, presentation } };
}
// -------------------------------------------------------------------------
// Legacy Socket streaming (op B)
// -------------------------------------------------------------------------
async prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {
...(input.provider ? { provider: input.provider } : {}),
...(input.modelId ? { modelId: input.modelId } : {}),
...(input.agentId ? { agentConfigId: input.agentId } : {}),
});
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded socket subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
input.content,
input.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Thinking level (op C) — synchronous, total
// -------------------------------------------------------------------------
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
return {
ok: false,
code: 'thinking_level_invalid',
retryable: false,
availableThinkingLevels,
};
}
session.piSession.setThinkingLevel(level as never);
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Abort (op D)
// -------------------------------------------------------------------------
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
try {
await session.piSession.abort();
} catch (err) {
this.logger.error(
`Legacy abort failed for conversation=${context.conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
}
// -------------------------------------------------------------------------
// Model override (synchronous, total)
// -------------------------------------------------------------------------
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
return { ok: true, value: this.presentationForSession(refreshed) };
}
// -------------------------------------------------------------------------
// Presentation read (synchronous, total)
// -------------------------------------------------------------------------
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Verified Discord ingress (embedded-only in both modes)
// -------------------------------------------------------------------------
async dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(
conversationId,
scope,
{ agentConfigId: context.configuredAgent.agentConfigId },
{
agentConfigId: context.configuredAgent.agentConfigId,
instanceId: context.configuredAgent.instanceId,
},
);
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded Discord subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
context.content,
context.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/**
* Resolves the owned session, creating it on first use. Ownership/scope rejections
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
* session presentation so callers avoid a redundant `getSession`.
*/
private async resolveOrCreate(
conversationId: string,
scope: ActorTenantScope,
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
): Promise<
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
// A verified-Discord turn may only run under a session whose configured identity matches the
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
// pre-existing session AND a freshly created one: a session carrying a different configured
// agent — however it arose — is rejected rather than executed under the verified label, so we
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
// no expectedAgent and skips the check.
const identityMatches = (candidate: AgentSession): boolean =>
expectedAgent === undefined ||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
candidate.agentName === expectedAgent.instanceId);
let session = this.agentService.getSession(conversationId, scope);
if (session && !identityMatches(session)) {
// Reused same-scope session minted under a different configured identity — reject with zero
// effects rather than dispatch a verified turn onto a foreign agent's session.
return CONVERSATION_UNAVAILABLE;
}
if (!session) {
try {
session = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
...extraOptions,
});
} catch (err) {
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
return CONVERSATION_UNAVAILABLE;
}
this.logger.error(
`Embedded session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
// The just-created session must ALSO carry the reconciled identity before any effect. A
// createSession that returns a session under a different configured agent (misconfiguration
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
if (!identityMatches(session)) {
return CONVERSATION_UNAVAILABLE;
}
}
return { ok: true, presentation: this.presentationForSession(session) };
}
/** Installs a normalizing event listener that forwards to the server-owned stream. */
private subscribe(
conversationId: string,
scope: ActorTenantScope,
stream: LegacyRuntimeStream,
): () => void {
const unsubscribe = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
const normalized = this.normalizeEvent(conversationId, scope, event);
if (normalized) stream.onEvent(normalized);
},
scope,
);
try {
this.agentService.addChannel(conversationId, stream.channelId, scope);
} catch (err) {
// Partial setup: the listener was acquired but the channel attach failed. Roll back
// exactly what was acquired (the listener) before the failure escapes, so no leaked
// subscription survives; the caller converts the rethrow into a total safe failure.
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
throw err;
}
return () => {
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
try {
this.agentService.removeChannel(conversationId, stream.channelId, scope);
} catch {
/* idempotent teardown */
}
};
}
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
private buildLease(
conversationId: string,
scope: ActorTenantScope,
content: string,
attachments: VerifiedDiscordIngressContext['attachments'],
detach: () => void,
presentation: LegacySessionPresentation,
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
let dispatched = false;
let disposed = false;
return {
presentation,
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
if (dispatched) {
return { ok: false, code: 'turn_already_dispatched', retryable: false };
}
dispatched = true;
try {
await this.agentService.prompt(conversationId, content, scope, attachments);
} catch (err) {
this.logger.error(
`Legacy dispatch failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
},
dispose: async (): Promise<void> => {
if (disposed) return;
disposed = true;
detach();
},
};
}
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
private normalizeEvent(
conversationId: string,
scope: ActorTenantScope,
event: AgentSessionEvent,
): LegacyRuntimeEvent | undefined {
switch (event.type) {
case 'agent_start':
return { type: 'started' };
case 'agent_end':
return { type: 'settled', ...this.usageFor(conversationId, scope) };
case 'message_update': {
const assistant = event.assistantMessageEvent;
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
if (assistant.type === 'thinking_delta') {
return { type: 'thinking_delta', text: assistant.delta };
}
return undefined;
}
case 'tool_execution_start':
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
case 'tool_execution_end':
return {
type: 'tool_finished',
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
};
default:
return undefined;
}
}
/**
* Gathers terminal usage from the Pi session and records it into session metrics.
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
*/
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
const session = this.agentService.getSession(conversationId, scope);
const piSession = session?.piSession;
const stats = piSession?.getSessionStats();
if (!session || !stats) return {};
const contextUsage = piSession?.getContextUsage();
const tokens = {
input: stats.tokens?.input ?? 0,
output: stats.tokens?.output ?? 0,
cacheRead: stats.tokens?.cacheRead ?? 0,
cacheWrite: stats.tokens?.cacheWrite ?? 0,
total: stats.tokens?.total ?? 0,
};
this.agentService.recordTokenUsage(conversationId, { ...tokens });
return {
usage: {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: piSession?.thinkingLevel ?? 'off',
tokens,
cost: stats.cost ?? 0,
context: {
percent: contextUsage?.percent ?? null,
window: contextUsage?.contextWindow ?? 0,
},
},
};
}
/** Presentation from a live session id, or undefined when no owned session exists. */
private presentationFor(
conversationId: string,
scope: ActorTenantScope,
): LegacySessionPresentation | undefined {
const session = this.agentService.getSession(conversationId, scope);
return session ? this.presentationForSession(session) : undefined;
}
/** User-facing projection carrying no session handle, credential, or raw stats. */
private presentationForSession(session: AgentSession): LegacySessionPresentation {
return {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: session.piSession.thinkingLevel,
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
...(session.agentName ? { agentName: session.agentName } : {}),
};
}
}
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
const CONVERSATION_UNAVAILABLE = {
ok: false as const,
code: 'conversation_unavailable' as const,
retryable: false as const,
};
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
return { userId: scope.userId, tenantId: scope.tenantId };
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessActorContext,
HarnessConversationService,
HarnessEventEnvelope,
HarnessSelection,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
* exclusively through the {@link HarnessConversationService} RPC boundary and
* forwards the caller's exact selection tuple and idempotency key without
* substitution. Red-first: the runtime is an unimplemented stub, so every
* delegation assertion fails until Step Three.
*/
const context: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'corr-1',
};
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude-opus-4-8',
};
const conversationId = '11111111-1111-4111-8111-111111111111';
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
context,
conversationId,
selection,
turnId: 'turn-abc',
correlationId: 'corr-1',
content: 'hello',
idempotencyKey,
};
const attachInput: AttachConversation & { afterSequence?: number } = {
context,
conversationId,
clientId: 'client-1',
selection,
afterSequence: 0,
};
const detachInput: DetachConversation = {
context,
conversationId,
clientId: 'client-1',
};
interface RecordedCalls {
attach: (AttachConversation & { afterSequence?: number })[];
detach: DetachConversation[];
send: (SendHarnessTurn & { idempotencyKey: string })[];
subscribeFrom: { conversationId: string; afterSequence: number }[];
}
const snapshot: ConversationSnapshot = {
session: {
conversationId,
nativeSessionId: 'native-1',
seatId: 'seat-1',
selection,
state: 'idle',
attachedClientIds: ['client-1'],
},
lastSequence: 0,
replay: [],
};
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
const service: HarnessConversationService = {
attach: (input) => {
calls.attach.push(input);
return Promise.resolve(snapshot);
},
detach: (input) => {
calls.detach.push(input);
return Promise.resolve();
},
send: (input) => {
calls.send.push(input);
// The service echoes only the requested tuple; there is no representable substitute.
const receipt: TurnReceipt = {
conversationId: input.conversationId,
turnId: 'turn-server',
correlationId: input.correlationId,
state: 'accepted',
selection: input.selection,
};
return Promise.resolve(receipt);
},
subscribeFrom: (id, afterSequence) => {
calls.subscribeFrom.push({ conversationId: id, afterSequence });
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
return;
})();
},
};
return { runtime: new HarnessChatRuntime(service), calls };
}
describe('HarnessChatRuntime', () => {
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
const { runtime } = build();
expect(runtime.kind).toBe('harness');
});
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
const { runtime, calls } = build();
const receipt = await runtime.send(sendInput);
expect(calls.send).toHaveLength(1);
const firstSend = calls.send[0]!;
expect(firstSend).toEqual(sendInput);
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
expect(firstSend.selection).toEqual(selection);
// The runtime must not substitute an effective tuple onto the receipt.
expect(receipt.selection).toEqual(selection);
});
it('delegates attach to the conversation service and returns its snapshot', async () => {
const { runtime, calls } = build();
const result = await runtime.attach(attachInput);
expect(calls.attach).toHaveLength(1);
expect(calls.attach[0]).toEqual(attachInput);
expect(result).toBe(snapshot);
});
it('delegates detach to the conversation service', async () => {
const { runtime, calls } = build();
await runtime.detach(detachInput);
expect(calls.detach).toHaveLength(1);
expect(calls.detach[0]).toEqual(detachInput);
});
it('delegates subscribeFrom to the conversation service journal replay', async () => {
const { runtime, calls } = build();
const iterable = runtime.subscribeFrom(conversationId, 7);
// Drain to prove it is the service-backed async iterable, not a fabricated one.
const drained: unknown[] = [];
for await (const event of iterable) {
drained.push(event);
}
expect(drained).toHaveLength(0);
expect(calls.subscribeFrom).toHaveLength(1);
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
});
});
@@ -0,0 +1,47 @@
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessConversationService,
HarnessEventEnvelope,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import type { ChatRuntime } from './chat-runtime.js';
/**
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
* stack, and it forwards the caller's exact selection tuple and idempotency key
* without substitution.
*
* It owns no state and adds no policy: every method forwards the caller's exact
* argument to the injected {@link HarnessConversationService} and returns its
* result unchanged, so the requested selection tuple and idempotency key can
* never be substituted on the way through.
*/
export class HarnessChatRuntime implements ChatRuntime {
readonly kind = 'harness' as const;
constructor(private readonly conversations: HarnessConversationService) {}
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
return this.conversations.attach(input);
}
detach(input: DetachConversation): Promise<void> {
return this.conversations.detach(input);
}
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
return this.conversations.send(input);
}
subscribeFrom(
conversationId: string,
afterSequence: number,
): AsyncIterable<HarnessEventEnvelope> {
return this.conversations.subscribeFrom(conversationId, afterSequence);
}
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Task 5 harness fence for the conversations REST write path.
*
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
*
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
* `process.env` at request time. The two "env is flipped after construction" tests below are the
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
*/
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const USER = { id: 'user-1' };
function sendMessageDto() {
return {
role: 'user' as const,
content: 'hello from the legacy REST write path',
metadata: undefined,
};
}
function brainWithMessageSpy() {
const addMessage = vi.fn().mockResolvedValue({
id: 'message-1',
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
});
return {
brain: { conversations: { addMessage } } as never,
addMessage,
};
}
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
function routerFixedTo(mode: ChatRuntimeMode) {
return { runtimeMode: mode };
}
let priorMode: string | undefined;
describe('conversations REST write path — Task 5 harness fence', () => {
beforeEach(() => {
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
});
afterEach(() => {
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
});
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
// written, so no duplicate message can be produced.
expect(addMessage).not.toHaveBeenCalled();
});
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledWith(
{
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
metadata: undefined,
},
USER.id,
);
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
});
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
expect(addMessage).not.toHaveBeenCalled();
});
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledTimes(1);
});
});
@@ -6,6 +6,7 @@ import {
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Inject,
NotFoundException,
@@ -19,6 +20,7 @@ import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -26,10 +28,41 @@ import {
SearchMessagesDto,
} from './conversations.dto.js';
/**
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
*/
class HarnessRuntimeWriteUnsupportedException extends HttpException {
readonly code = 'runtime_unsupported' as const;
constructor() {
super(
{
code: 'runtime_unsupported',
message:
'Conversation message writes are handled by the harness runtime on this deployment.',
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@Controller('api/conversations')
@UseGuards(AuthGuard)
export class ConversationsController {
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
/**
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
* request time — a single source of truth, so the controller cannot disagree with the router
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
* so this class depends on nothing else the router exposes.
*/
constructor(
@Inject(BRAIN) private readonly brain: Brain,
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
@@ -94,6 +127,13 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
// harness path owns persistence there, so a direct write would duplicate the message. The mode
// comes from the router's init-time resolution, not a request-time env read.
if (this.router.runtimeMode === 'pi-rpc') {
throw new HarnessRuntimeWriteUnsupportedException();
}
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
@@ -1,7 +1,14 @@
import { Module } from '@nestjs/common';
import { ChatModule } from '../chat/chat.module.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
*/
@Module({
imports: [ChatModule],
controllers: [ConversationsController],
})
export class ConversationsModule {}
+11 -2
View File
@@ -1,7 +1,12 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
HARNESS_SERVICE,
} from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
@@ -20,9 +25,13 @@ import { HarnessSelectionRepository } from './harness-selection.repository.js';
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
// Task Five: bind the conversation-service token to its explicit "not yet bound"
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
// replaces it with a real service. Exported so ChatModule's router can inject it.
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
})
export class HarnessModule {}
@@ -4,8 +4,42 @@
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
import type { HarnessConversationService } from '@mosaicstack/types';
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
/**
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
*/
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
/**
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
* distinct sentinel — never `null`/`undefined` — so an unbound service is an
* intentional, checkable state rather than an accidental nil that could read as
* "present". Replaced by a real service in Task 14.
*/
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
);
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
export type HarnessConversationServiceBinding =
| HarnessConversationService
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
export function isHarnessConversationServiceAvailable(
binding: HarnessConversationServiceBinding,
): binding is HarnessConversationService {
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
}
@@ -12,6 +12,10 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
@@ -25,6 +29,7 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
'CHAT_HARNESS_RUNTIME',
] as const;
const savedEnv = new Map<string, string | undefined>();
@@ -150,6 +155,57 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
};
}
/**
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
*
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
*
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
* and never from a browser-emittable socket event (the browser-forgery refusal test).
*/
function readyPiRpcRegistry(): HarnessRegistry {
const registry = new HarnessRegistry();
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
// router resolve `active` = harness instead of failing closed at init, so these tests model the
// real hostile condition — the harness runtime IS live — rather than a degraded router.
registry.register({ id: 'pi' } as never);
return registry;
}
function piRpcRouterFronting(
agentService: unknown,
harnessConversations: { append: ReturnType<typeof vi.fn> },
): ChatRuntimeRouter {
const routerConversationServiceTripwire = {
append: () => {
throw new Error('router conversation service must not be resolved on the Discord path');
},
};
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(harnessConversations as never);
const router = new ChatRuntimeRouter(
readyPiRpcRegistry(),
routerConversationServiceTripwire as never,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
return router;
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
@@ -433,6 +489,7 @@ describe('Discord ingress security', () => {
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
@@ -489,8 +546,9 @@ describe('Discord ingress security', () => {
},
};
const routingEngine = { resolve: vi.fn() };
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
@@ -531,6 +589,575 @@ describe('Discord ingress security', () => {
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
// never touch it — the create path stays on the embedded runtime.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-replay',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
conversationId: 'Nova:discord:channel-001',
});
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// The harness runtime is never touched on either delivery.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-claim-ordering',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
conversationId: 'Nova:discord:channel-001',
});
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
// replay claim for `discord-order-001` even though nothing dispatched.
delete process.env['DISCORD_SERVICE_USER_ID'];
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
// blocks it, so every effect remains at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The durable agent record does not reconcile on the first delivery (its name no longer matches
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with stale agent record',
'discord-reconcile-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
const foreignIdentitySession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
const agentService = {
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-identity-swap',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
// then admit the corrected record whose id matches the binding.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile-id',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with aliased agent id',
'discord-reconcile-id-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
// another agent's verified label. (The existing reuse test covers the getSession path; this
// covers the createSession path scrappy flagged as unvalidated.)
const mintedForeignSession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-postcreate-mismatch',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// The freshly minted session failed the post-create identity recheck: refused with a typed
// error, no prompt, no persist, no ack.
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
@@ -593,11 +1220,16 @@ describe('Discord ingress security', () => {
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const session = {
provider: 'test-provider',
modelId: 'test-model',
// The reused embedded session carries the SAME reconciled identity as the verified binding,
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -611,6 +1243,7 @@ describe('Discord ingress security', () => {
prompt,
};
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
@@ -618,8 +1251,9 @@ describe('Discord ingress security', () => {
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
@@ -667,6 +1301,66 @@ describe('Discord ingress security', () => {
}),
'discord-service',
);
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
// the router resolved as `active` is never reached.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
// the harness. There is no dedicated socket event for verified ingress — the only ingress
// surface is the generic `message` handler, and a non-service client is refused there.
//
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession: vi.fn(),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'browser-forging-discord',
data: { discordService: false },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
const refusal = client.emit.mock.calls.find(
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
);
expect(refusal).toBeDefined();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversations.append).not.toHaveBeenCalled();
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('accepts a thread message through its allowed bound parent channel', () => {
+10
View File
@@ -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,
+39
View File
@@ -1,3 +1,42 @@
import type {
HarnessAuthState,
HarnessModelAvailability,
HarnessSelection,
} from '@mosaicstack/types';
// The exact harness/provider/model tuple and its closed enum companions are the
// shared domain types — re-exported here so web consumers (and the runtime
// guards) import one shape, never a divergent local redefinition.
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
* harness id is kept distinct from any provider id — they are never merged. */
export interface HarnessSummary {
id: string;
displayName: string;
capabilities: string[];
}
/** One selectable model in a harness catalog. Extends the `{harnessId,
* providerId, modelId}` tuple with the display/availability metadata the UI
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
* because it arrives from untrusted JSON and is only ever displayed. */
export interface HarnessCatalogEntry extends HarnessSelection {
displayName: string;
reasoningCapability: boolean;
inputTypes: string[];
authState: HarnessAuthState;
availability: HarnessModelAvailability;
}
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
export interface HarnessCatalog {
harnessId: string;
version: string;
fingerprint: string;
models: HarnessCatalogEntry[];
}
/** Conversation returned by the gateway API. */
export interface Conversation {
id: string;
+195
View File
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
fetchCatalog,
fetchHarnesses,
fetchPersistedSelection,
persistSelection,
} from './chat-api';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function stubFetch(): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
/** Every URL the client actually requested, across all calls. */
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => String(call[0]));
}
describe('chat-api', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json([
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
]),
);
const harnesses = await fetchHarnesses();
expect(fetchMock).toHaveBeenCalledOnce();
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
expect(harnesses).toEqual([
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
]);
});
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json({
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'abc123',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
}),
);
const result = await fetchCatalog('pi');
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('expected ok catalog');
expect(result.catalog.harnessId).toBe('pi');
expect(result.catalog.models).toHaveLength(1);
expect(result.catalog.models[0]).toMatchObject({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
availability: 'available',
});
});
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json(
{
code: 'adapter_unavailable',
message: 'raw gateway detail that must not leak verbatim',
harnessId: 'attacker-echo',
extra: { hostile: 'blob' },
},
404,
),
);
const result = await fetchCatalog('ghost');
expect(result.ok).toBe(false);
if (result.ok) throw new Error('expected unavailable result');
expect(result.code).toBe('catalog_unavailable');
// harnessId comes from the request, never the (untrusted) response body.
expect(result.harnessId).toBe('ghost');
expect(typeof result.message).toBe('string');
// The raw response body is never rendered/returned verbatim.
expect(JSON.stringify(result)).not.toContain('hostile');
expect(JSON.stringify(result)).not.toContain('attacker-echo');
});
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValueOnce(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
await expect(fetchPersistedSelection()).resolves.toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
fetchMock.mockResolvedValueOnce(json({ selection: null }));
await expect(fetchPersistedSelection()).resolves.toBeNull();
});
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
const result = await persistSelection({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(result.ok).toBe(true);
const call = fetchMock.mock.calls[0];
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
const init = call?.[1] as RequestInit;
expect(String(init.method).toUpperCase()).toBe('PUT');
// The body is exactly the structured tuple — harness/provider/model kept distinct.
expect(JSON.parse(String(init.body))).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
});
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(
json(
{
code: 'model_unavailable',
message: 'raw detail that must not leak',
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
},
422,
),
);
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
const result = await persistSelection(requested);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('expected failed persist');
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
// The requested tuple is preserved unchanged — not replaced by the body's echo.
expect(result.requested).toEqual(requested);
expect(JSON.stringify(result)).not.toContain('raw detail');
});
it('never requests any /api/providers* endpoint', async () => {
const fetchMock = stubFetch();
fetchMock.mockResolvedValue(json([]));
await fetchHarnesses();
fetchMock.mockResolvedValue(
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
);
await fetchCatalog('pi');
fetchMock.mockResolvedValue(json({ selection: null }));
await fetchPersistedSelection();
for (const url of requestedUrls(fetchMock)) {
expect(url).not.toContain('/api/providers');
}
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
* UI depends on. Every response body is untrusted and is normalized through the
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
* are mapped to typed, body-free error results so a raw gateway body is never
* rendered, and the caller's requested tuple is preserved verbatim on failure.
*
* This module talks ONLY to the harness/chat-preferences endpoints. It never
* calls `/api/providers*` — provider identity lives inside the harness catalog.
*/
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
/** A catalog fetch either yields the typed catalog or a typed unavailability —
* never a thrown raw body. */
export type CatalogResult =
| { ok: true; catalog: HarnessCatalog }
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
/** A persist either confirms the stored tuple or reports a typed domain failure
* that echoes back the exact tuple the caller requested. */
export type SelectionPersistResult =
| { ok: true; selection: HarnessSelection }
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
/** A safe, generic message for an unavailable catalog — the raw 404 body is
* never surfaced. */
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
/** A safe, generic message for a rejected selection. The untrusted 422 body's
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
* read from the body. */
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
async function readJson(response: Response): Promise<unknown> {
return response.json().catch(() => null);
}
function safeSelectionCode(body: unknown): SelectionErrorCode {
if (typeof body === 'object' && body !== null && 'code' in body) {
const code = (body as { code: unknown }).code;
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
}
// Default to the more conservative "invalid" classification for anything
// unrecognized rather than guessing "model_unavailable".
return 'selection_invalid';
}
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
* normalizes to an empty list (the UI then has no harness to select). */
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
const response = await fetch('/api/harnesses', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) return [];
return asHarnessSummaries(await readJson(response));
}
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
* fallback catalog or a rendered raw body. */
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return {
ok: false,
code: 'catalog_unavailable',
// Scoped to the requested harness id, never the untrusted body's echo.
harnessId,
message: CATALOG_UNAVAILABLE_MESSAGE,
};
}
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
}
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
* unset or malformed. */
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
const response = await fetch('/api/chat/preferences/selection', {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!response.ok) return null;
const body = await readJson(response);
if (typeof body !== 'object' || body === null) return null;
return asHarnessSelection((body as { selection?: unknown }).selection);
}
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
* On success returns the confirmed selection; on a typed domain failure (422)
* or validation error, returns a typed result carrying the EXACT requested
* tuple — never the body's echo — and never the raw body text. */
export async function persistSelection(
selection: HarnessSelection,
): Promise<SelectionPersistResult> {
const requested: HarnessSelection = {
harnessId: selection.harnessId,
providerId: selection.providerId,
modelId: selection.modelId,
};
const response = await fetch('/api/chat/preferences/selection', {
method: 'PUT',
credentials: 'include',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(requested),
});
if (!response.ok) {
const body = await readJson(response);
return {
ok: false,
code: safeSelectionCode(body),
// Fixed copy only — the untrusted body's message is never surfaced.
message: SELECTION_REJECTED_MESSAGE,
requested,
};
}
const body = await readJson(response);
const confirmed =
typeof body === 'object' && body !== null
? asHarnessSelection((body as { selection?: unknown }).selection)
: null;
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
// the server accepted the tuple, so the requested tuple is the source of truth.
return { ok: true, selection: confirmed ?? requested };
}
+103 -20
View File
@@ -1,7 +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
@@ -9,6 +11,23 @@ interface ComposerProps {
* pre-ack window where a second send could otherwise slip through. */
sending: boolean;
hasConversation: boolean;
/** Structured harness/provider/model selection state. The composer never
* accepts free-text provider/model — every sendable tuple is a validated,
* persisted catalog entry, and the send projection is derived from it. */
harness: HarnessSelectionValue;
}
/** The distinct provider ids present in the current catalog, in first-seen
* order — the provider select is catalog-derived, never a hardcoded list. */
function providerOptions(harness: HarnessSelectionValue): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const model of harness.catalog?.models ?? []) {
if (seen.has(model.providerId)) continue;
seen.add(model.providerId);
out.push(model.providerId);
}
return out;
}
export function Composer({
@@ -17,22 +36,26 @@ export function Composer({
streaming,
sending,
hasConversation,
harness,
}: ComposerProps): ReactElement {
const [content, setContent] = useState('');
const [provider, setProvider] = useState('');
const [modelId, setModelId] = useState('');
const busy = streaming || sending;
function submit(): void {
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 || harness.persistedSelection === null) return;
const trimmed = content.trim();
if (!trimmed) return;
onSend({
content: trimmed,
provider: provider.trim() || undefined,
modelId: modelId.trim() || undefined,
});
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 {
@@ -42,6 +65,19 @@ export function Composer({
}
}
// Scope the model options to the intentionally selected provider. With no
// provider chosen (`providerId === ''`) nothing matches, so the model select
// offers only the placeholder — never a cross-provider row.
const models = (harness.catalog?.models ?? []).filter(
(model) => model.providerId === harness.providerId,
);
// A collision-safe composite option identity covering the full provider+model
// tuple. The controlled select mirrors the same identity so the exact catalog
// row highlights (a bare modelId would collide across providers).
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
`${model.providerId}:${model.modelId}`;
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
return (
<form
onSubmit={(event) => {
@@ -51,21 +87,68 @@ export function Composer({
className="flex flex-col gap-2 border-t p-4"
>
<div className="flex flex-wrap gap-2">
<input
<select
aria-label="Harness"
value={harness.harnessId}
onChange={(event) => harness.selectHarness(event.target.value)}
className="rounded border px-2 py-1 text-xs"
>
<option value="">Select a harness</option>
{harness.harnesses.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName}
</option>
))}
</select>
<select
aria-label="Provider"
value={provider}
onChange={(event) => setProvider(event.target.value)}
placeholder="Provider (optional)"
value={harness.providerId}
onChange={(event) => harness.selectProvider(event.target.value)}
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
className="rounded border px-2 py-1 text-xs"
/>
<input
>
<option value="">Select a provider</option>
{providerOptions(harness).map((providerId) => (
<option key={providerId} value={providerId}>
{providerId}
</option>
))}
</select>
<select
aria-label="Model"
value={modelId}
onChange={(event) => setModelId(event.target.value)}
placeholder="Model (optional)"
value={selectedModelValue}
onChange={(event) => {
// Resolve the composite option identity back to the exact catalog
// row and persist that row's own provider+model — never a bare id.
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
if (selected) harness.selectModel(selected.providerId, selected.modelId);
}}
disabled={harness.catalogUnavailable || models.length === 0}
className="rounded border px-2 py-1 text-xs"
/>
>
<option value="">Select a model</option>
{models.map((model) => (
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
{model.displayName}
</option>
))}
</select>
</div>
{harness.catalogUnavailable ? (
<p role="status" className="text-xs opacity-70">
This harness catalog is currently unavailable.
</p>
) : null}
{harness.isStale ? (
<p role="status" className="text-xs opacity-70">
The saved model is no longer available pick another to continue.
</p>
) : null}
{harness.persistError ? (
<p role="alert" className="text-xs">
{harness.persistError.message}
</p>
) : null}
<div className="flex items-end gap-2">
<textarea
aria-label="Message"
@@ -78,7 +161,7 @@ export function Composer({
/>
<button
type="submit"
disabled={!content.trim() || busy}
disabled={!content.trim() || busy || !harness.canSend}
className="rounded px-3 py-2 text-sm font-medium"
>
Send
+101
View File
@@ -5,6 +5,14 @@
* a non-array, `.toFixed` on a non-number) or render an object as a React
* child.
*/
import type {
HarnessAuthState,
HarnessCatalog,
HarnessCatalogEntry,
HarnessModelAvailability,
HarnessSelection,
HarnessSummary,
} from '@/lib/types';
export function asString(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
@@ -38,6 +46,99 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/**
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
* payloads above — a misbehaving or compromised gateway can send anything. The
* guards below normalize those bodies into the typed client shapes without ever
* rendering a raw body, so a 404/422/malformed response can never inject an
* object into React or a non-tuple into the selection state.
*/
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
* safest value (`unavailable`) for anything unrecognized. */
export function asHarnessAuthState(value: unknown): HarnessAuthState {
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
? value
: 'unavailable';
}
/** Normalizes an untrusted `availability` to the closed set, defaulting to
* `unavailable` so a malformed row can never present as sendable. */
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
return value === 'available' ? 'available' : 'unavailable';
}
/** A tuple is valid only when all three ids are non-empty strings — a partial
* or malformed selection is rejected (null) rather than half-adopted. */
export function asHarnessSelection(value: unknown): HarnessSelection | null {
if (!isRecord(value)) return null;
const harnessId = value.harnessId;
const providerId = value.providerId;
const modelId = value.modelId;
if (
typeof harnessId !== 'string' ||
typeof providerId !== 'string' ||
typeof modelId !== 'string' ||
harnessId.length === 0 ||
providerId.length === 0 ||
modelId.length === 0
) {
return null;
}
return { harnessId, providerId, modelId };
}
/** Normalizes an untrusted array into typed harness summaries, dropping any row
* without a usable id. */
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
if (!Array.isArray(value)) return [];
const out: HarnessSummary[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const id = asString(item.id);
if (id.length === 0) continue;
out.push({
id,
displayName: asNonEmptyString(item.displayName, id),
capabilities: asStringArray(item.capabilities),
});
}
return out;
}
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
const selection = asHarnessSelection(value);
if (selection === null || !isRecord(value)) return null;
return {
...selection,
displayName: asNonEmptyString(value.displayName, selection.modelId),
reasoningCapability: value.reasoningCapability === true,
inputTypes: asStringArray(value.inputTypes),
authState: asHarnessAuthState(value.authState),
availability: asHarnessAvailability(value.availability),
};
}
/** Normalizes an untrusted catalog body into the typed client catalog. The
* caller supplies `harnessId` (from the request path) so the returned catalog
* is scoped to the harness that was actually requested, never a body-echoed id.
* Malformed model rows are dropped rather than invalidating the whole catalog. */
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
const record = isRecord(value) ? value : {};
const rawModels = Array.isArray(record.models) ? record.models : [];
const models: HarnessCatalogEntry[] = [];
for (const row of rawModels) {
const entry = asHarnessCatalogEntry(row);
if (entry !== null) models.push(entry);
}
return {
harnessId,
version: asString(record.version),
fingerprint: asString(record.fingerprint),
models,
};
}
/** The single point of truth for what counts as a valid conversation ID
* anywhere a scoped server event may adopt one into state — a non-empty
* string, nothing else. Every site that establishes or compares
@@ -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);
});
});
});
+250 -13
View File
@@ -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: () => {
@@ -0,0 +1,450 @@
import { act, type ReactElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
interface Scenario {
harnesses?: unknown;
catalog?: { body: unknown; status?: number };
selection?: unknown;
/** When set, the PUT resolves only when this is called (for race tests). */
deferPut?: boolean;
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function defer<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
let putBodies: unknown[] = [];
let putDeferred: Deferred<Response> | null = null;
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
putBodies = [];
putDeferred = scenario.deferPut ? defer<Response>() : null;
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
const spec = scenario.catalog ?? {
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
};
return json(spec.body, spec.status ?? 200);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return json({ selection: scenario.selection ?? null });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
putBodies.push(JSON.parse(String(init?.body)));
const ok = json({ selection: JSON.parse(String(init?.body)) });
if (putDeferred) return putDeferred.promise;
return ok;
}
return new Response('not found', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
let latest: HarnessSelectionValue | null = null;
function Probe(): ReactElement | null {
latest = useHarnessSelection();
return null;
}
let root: Root | null;
let container: HTMLElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
beforeEach(() => {
latest = null;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
vi.unstubAllGlobals();
});
async function mount(): Promise<void> {
await act(async () => {
root?.render(<Probe />);
});
await flush();
}
async function flush(times = 5): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
function value(): HarnessSelectionValue {
if (!latest) throw new Error('hook value not captured');
return latest;
}
const PI_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
displayName: 'Claude',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
describe('useHarnessSelection', () => {
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
expect(value().catalog).toBeNull();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalog?.harnessId).toBe('pi');
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
});
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
const fetchMock = installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().modelId).toBe('');
expect(value().persistedSelection).toBeNull();
expect(value().canSend).toBe(false);
// Nothing was persisted — no PUT fired for an unset selection.
const putCalls = fetchMock.mock.calls.filter(
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
);
expect(putCalls).toHaveLength(0);
});
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
deferPut: true,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
expect(value().canSend).toBe(false);
await act(async () => {
putDeferred?.resolve(
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
);
});
await flush();
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(value().canSend).toBe(true);
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
// harnessId must never be dropped on the way to the wire.
expect('projection' in value()).toBe(false);
});
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
});
await mount();
// The persisted tuple is displayed even though its model is gone from the catalog.
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'retired-model',
});
expect(value().modelId).toBe('retired-model');
expect(value().isStale).toBe(true);
// A stale model is not a valid catalog option, so send stays disabled.
expect(value().canSend).toBe(false);
});
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalog?.models ?? []).toHaveLength(0);
expect(value().canSend).toBe(false);
});
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: {
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
status: 404,
},
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
expect(value().catalogUnavailable).toBe(true);
expect(value().canSend).toBe(false);
});
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: {
body: {
...PI_CATALOG,
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
},
},
selection: null,
});
// Override PUT to 422.
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses')
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
if (url.endsWith('/catalog')) return json(PI_CATALOG);
if (url === '/api/chat/preferences/selection' && method === 'GET')
return json({ selection: null });
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return json(
{
code: 'model_unavailable',
message: 'nope',
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
},
422,
);
}
return new Response('nf', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
expect(value().modelId).toBe('gpt-5');
expect(value().persistError?.code).toBe('model_unavailable');
expect(value().persistError?.requested).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
expect(value().persistedSelection).toBeNull();
expect(value().canSend).toBe(false);
});
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: null,
});
await mount();
await act(async () => {
value().selectHarness('pi');
});
await flush();
await act(async () => {
value().selectProvider('openai');
});
await act(async () => {
value().selectModel('openai', 'gpt-5');
});
await flush();
// A valid provider-A tuple has persisted.
expect(value().canSend).toBe(true);
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
});
// Switching provider clears the model that no longer belongs to it.
await act(async () => {
value().selectProvider('anthropic');
});
expect(value().modelId).toBe('');
expect(value().canSend).toBe(false);
// Send stays disabled until the new exact provider-B tuple persists.
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
expect(value().canSend).toBe(true);
expect(value().persistedSelection).toEqual({
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
});
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
});
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
deferPut: true,
});
await mount();
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
expect(value().canSend).toBe(true);
await act(async () => {
value().selectProvider('anthropic');
});
expect(value().modelId).toBe('');
expect(value().canSend).toBe(false);
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
// PUT for the new tuple is still in flight — send MUST stay disabled.
expect(value().canSend).toBe(false);
await act(async () => {
putDeferred?.resolve(
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
);
});
await flush();
expect(value().canSend).toBe(true);
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
});
it('never requests any /api/providers* endpoint across the whole flow', async () => {
const fetchMock = installFetch({
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
catalog: { body: PI_CATALOG },
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
});
await mount();
await act(async () => {
value().selectProvider('anthropic');
});
await act(async () => {
value().selectModel('anthropic', 'claude');
});
await flush();
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).not.toContain('/api/providers');
}
});
});
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
fetchCatalog,
fetchHarnesses,
fetchPersistedSelection,
persistSelection,
type SelectionErrorCode,
} from './chat-api';
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
export interface HarnessPersistError {
code: SelectionErrorCode;
message: string;
/** The exact tuple the user requested — preserved so the failed selection
* stays visible rather than being silently dropped. */
requested: HarnessSelection;
}
export interface HarnessSelectionValue {
harnesses: HarnessSummary[];
catalog: HarnessCatalog | null;
/** True when the selected harness has no usable catalog (404/error). */
catalogUnavailable: boolean;
/** The working (displayed) selection, kept as three distinct ids. Empty
* strings mean "not chosen yet" — there is deliberately no first-row default. */
harnessId: string;
providerId: string;
modelId: string;
/** The last tuple confirmed persisted by the server, or null. */
persistedSelection: HarnessSelection | null;
/** True when a persisted selection references a model no longer present as an
* available catalog entry — it stays visibly displayed rather than dropped. */
isStale: boolean;
/** True ONLY once a full tuple has been confirmed persisted AND it is a
* currently-available catalog entry. Send stays disabled otherwise, so a send
* can never race ahead of successful persistence. */
canSend: boolean;
persistError: HarnessPersistError | null;
selectHarness: (harnessId: string) => void;
selectProvider: (providerId: string) => void;
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
* 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;
}
/** A tuple is a currently-usable catalog option only when the catalog holds a
* matching, available entry — the single gate that keeps a stale/unavailable
* model from ever counting as sendable. */
function isAvailableInCatalog(
selection: HarnessSelection | null,
catalog: HarnessCatalog | null,
): boolean {
if (selection === null || catalog === null) return false;
return catalog.models.some(
(model) =>
model.providerId === selection.providerId &&
model.modelId === selection.modelId &&
model.availability === 'available',
);
}
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
if (a === null || b === null) return a === b;
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
}
/**
* Owns the harness/catalog/selection state for the chat composer: loads the
* harness list and any persisted tuple on mount, loads a harness's catalog when
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
* when a model is picked. It never auto-selects a catalog row, keeps a
* stale/unavailable persisted tuple visible, and only reports `canSend` true
* once a full tuple has actually persisted as an available catalog entry.
*/
export function useHarnessSelection(): HarnessSelectionValue {
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
const [harnessId, setHarnessId] = useState('');
const [providerId, setProviderId] = useState('');
const [modelId, setModelId] = useState('');
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
// Monotonic request ids so a slow in-flight catalog/persist response can never
// overwrite the result of a newer request the user has since triggered.
const catalogRequestRef = useRef(0);
const persistRequestRef = useRef(0);
const loadCatalog = useCallback(async (id: string): Promise<void> => {
const requestId = catalogRequestRef.current + 1;
catalogRequestRef.current = requestId;
setCatalog(null);
setCatalogUnavailable(false);
const result = await fetchCatalog(id);
if (catalogRequestRef.current !== requestId) return;
if (result.ok) {
setCatalog(result.catalog);
setCatalogUnavailable(false);
} else {
setCatalog(null);
setCatalogUnavailable(true);
}
}, []);
useEffect(() => {
let active = true;
void (async (): Promise<void> => {
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
if (!active) return;
setHarnesses(list);
if (persisted !== null) {
// Adopt the persisted tuple as the displayed selection and load its
// catalog. If the model has since been retired, it still shows (stale).
setHarnessId(persisted.harnessId);
setProviderId(persisted.providerId);
setModelId(persisted.modelId);
setPersistedSelection(persisted);
await loadCatalog(persisted.harnessId);
}
// No persisted selection → nothing is auto-selected; the user must choose.
})();
return () => {
active = false;
};
}, [loadCatalog]);
const selectHarness = useCallback(
(id: string): void => {
setHarnessId(id);
// Changing harness invalidates the provider/model draft — never carry a
// model across harnesses.
setProviderId('');
setModelId('');
setPersistError(null);
void loadCatalog(id);
},
[loadCatalog],
);
const selectProvider = useCallback((id: string): void => {
setProviderId(id);
// A new provider invalidates the chosen model — no cross-provider carryover.
setModelId('');
setPersistError(null);
}, []);
const selectModel = useCallback(
(selectedProviderId: string, selectedModelId: string): void => {
// Bind the model to the EXACT catalog row's provider — never to ambient
// provider state — so two providers exposing the same modelId can never
// collide or mis-resolve. Keep the displayed provider consistent with the
// resolved row.
setProviderId(selectedProviderId);
setModelId(selectedModelId);
setPersistError(null);
const requested: HarnessSelection = {
harnessId,
providerId: selectedProviderId,
modelId: selectedModelId,
};
const requestId = persistRequestRef.current + 1;
persistRequestRef.current = requestId;
void (async (): Promise<void> => {
const result = await persistSelection(requested);
if (persistRequestRef.current !== requestId) return;
if (result.ok) {
setPersistedSelection(result.selection);
setPersistError(null);
} else {
// Leave persistedSelection unchanged (send stays disabled) and surface
// the typed error carrying the exact requested tuple.
setPersistError({
code: result.code,
message: result.message,
requested: result.requested,
});
}
})();
},
[harnessId],
);
const draft: HarnessSelection = { harnessId, providerId, modelId };
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
const canSend =
persistedSelection !== null &&
!catalogUnavailable &&
tuplesEqual(draft, persistedSelection) &&
isAvailableInCatalog(persistedSelection, catalog);
return {
harnesses,
catalog,
catalogUnavailable,
harnessId,
providerId,
modelId,
persistedSelection,
isStale,
canSend,
persistError,
selectHarness,
selectProvider,
selectModel,
};
}
+498 -7
View File
@@ -38,6 +38,128 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement {
return button;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const DEFAULT_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'openai',
modelId: 'gpt-5',
displayName: 'GPT-5',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
displayName: 'Claude',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
/** A harness/catalog/selection HTTP stub for the chat-api the selection hook
* drives. `selection` seeds the persisted tuple returned by the GET (a valid
* in-catalog tuple by default, so `canSend` settles true after mount). */
function harnessFetch(
selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
): typeof fetch {
return vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') {
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
}
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
return jsonResponse(DEFAULT_CATALOG);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return jsonResponse({ selection });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
}
/** Drains the selection hook's chained mount fetches (harnesses → selection →
* catalog) and any pending PUT so derived `canSend` settles before assertions. */
async function flushAsync(times = 5): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
/** Deterministic idempotency key for the Task Five red-first page send test. */
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
* defineProperty on the crypto instance so it works whether or not the native
* method is configurable (it lives on the prototype; an own property shadows it). */
function installRandomUUID(fn: () => string): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: fn,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only
* as the server advertised, once per connection, over the server-to-client-only
* `chat:send-capability`. Model that advertisement for THIS connection id so the
* page send tests take the intended branch. `legacy-message` is the default
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
* reset the generation and re-advertise `turn-send` via the helper below.
*/
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
}
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
* default `legacy-message` advertisement + first-wins lock), then advertise the
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
* for the page send tests that route through `turn:send`. */
async function advertiseTurnSendGeneration(): Promise<void> {
await act(async () => {
fake.simulateReconnect();
});
await act(async () => {
advertiseSendCapability('turn-send');
});
}
let fake: ReturnType<typeof createFakeChatSocket>;
let root: Root | null;
let container: HTMLElement;
@@ -57,12 +179,22 @@ beforeEach(async () => {
fake = createFakeChatSocket();
getSocketMock.mockReset().mockReturnValue(fake.socket);
destroySocketMock.mockReset();
vi.stubGlobal('fetch', harnessFetch());
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ChatPage />);
});
// Settle the selection hook's mount fetches so the default in-catalog tuple
// persists and `canSend` is true for the existing send-path tests.
await flushAsync();
// Model the server's post-auth send-capability advertisement (MAJOR-1). Most
// page send tests exercise the legacy `message` branch; the pi turn-runtime
// tests override to `turn-send` via advertiseTurnSendGeneration().
await act(async () => {
advertiseSendCapability('legacy-message');
});
});
afterEach(async () => {
@@ -70,8 +202,28 @@ afterEach(async () => {
root?.unmount();
});
document.body.replaceChildren();
vi.unstubAllGlobals();
});
/** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for
* tests that need a non-default selection scenario. */
async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
await act(async () => {
root?.unmount();
});
vi.stubGlobal('fetch', fetchImpl);
root = createRoot(container);
await act(async () => {
root?.render(<ChatPage />);
});
await flushAsync();
// Re-advertise on the remounted connection — the prior generation's capability
// does not carry across a remount (fresh hook instance, unadvertised protocol).
await act(async () => {
advertiseSendCapability('legacy-message');
});
}
describe('ChatPage', () => {
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
await act(async () => {
@@ -390,24 +542,62 @@ describe('ChatPage', () => {
expect(container.querySelector('[role="alert"]')).toBeTruthy();
});
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => {
// The old free-text inputs are gone.
expect(container.querySelector('input[aria-label="Provider"]')).toBeNull();
expect(container.querySelector('input[aria-label="Model"]')).toBeNull();
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
expect(harnessSelect).toBeTruthy();
expect(providerSelect).toBeTruthy();
expect(modelSelect).toBeTruthy();
// Harness and provider are distinct controls carrying distinct identifiers.
expect(harnessSelect).not.toBe(providerSelect);
expect([...harnessSelect.options].map((o) => o.value)).toContain('pi');
expect([...providerSelect.options].map((o) => o.value)).toContain('openai');
expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic');
// The model options are catalog-derived (not hardcoded) and scoped to the
// selected provider (openai, from the persisted tuple) using a collision-safe
// composite identity — the anthropic row is absent, not a bare 'claude'.
const modelValues = [...modelSelect.options].map((o) => o.value);
expect(modelValues).toContain('openai:gpt-5');
expect(modelValues).not.toContain('anthropic:claude');
expect(modelValues).not.toContain('claude');
});
it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
const providerInput = container.querySelector(
'input[aria-label="Provider"]',
) as HTMLInputElement;
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
const stopButtonBefore = container.querySelector(
'button[aria-label="Stop"]',
) as HTMLButtonElement;
expect(stopButtonBefore.disabled).toBe(true);
// Choose a fresh tuple from the catalog and let it persist.
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'anthropic');
});
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
await act(async () => {
// Composite provider+model option identity (provider was switched to
// anthropic above); the bare 'claude' no longer identifies an option.
selectValue(modelSelect, 'anthropic:claude');
});
await flushAsync();
await act(async () => {
setValue(textarea, 'hello there');
setValue(providerInput, 'anthropic');
setValue(modelInput, 'claude');
});
await act(async () => {
textarea.dispatchEvent(
@@ -415,6 +605,7 @@ describe('ChatPage', () => {
);
});
// The projected provider/model come from the validated persisted tuple.
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
@@ -443,6 +634,178 @@ 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));
const sendButton = findButton(container, 'Send');
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'should not send');
});
// Content present, but no selection persisted → Send stays disabled.
expect(sendButton.disabled).toBe(true);
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
});
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
@@ -620,6 +983,134 @@ describe('ChatPage', () => {
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
});
it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => {
await remountWithFetch(harnessFetch(null));
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(harnessSelect, 'pi');
});
await flushAsync();
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'openai');
});
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
// Only the selected provider's models are offered — provider B's model
// (anthropic:claude) is absent, so a user cannot pick across providers.
expect(optionValues).toEqual(['openai:gpt-5']);
expect(optionValues).not.toContain('anthropic:claude');
});
it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => {
const COLLIDING_CATALOG = {
harnessId: 'pi',
version: '2026-08-11',
fingerprint: 'fp',
models: [
{
harnessId: 'pi',
providerId: 'alpha',
modelId: 'gpt-x',
displayName: 'Alpha GPT-X',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId: 'pi',
providerId: 'beta',
modelId: 'gpt-x',
displayName: 'Beta GPT-X',
reasoningCapability: true,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
],
};
const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
const method = String(init?.method ?? 'GET').toUpperCase();
if (url === '/api/harnesses') {
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
}
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
return jsonResponse(COLLIDING_CATALOG);
}
if (url === '/api/chat/preferences/selection' && method === 'GET') {
return jsonResponse({ selection: null });
}
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
await remountWithFetch(collidingFetch);
const harnessSelect = container.querySelector(
'select[aria-label="Harness"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(harnessSelect, 'pi');
});
await flushAsync();
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'alpha');
});
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
// The colliding modelId is provider-qualified in the option value, never a
// bare id, so the two providers' 'gpt-x' rows are uniquely identifiable.
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
expect(optionValues).toEqual(['alpha:gpt-x']);
await act(async () => {
selectValue(modelSelect, 'alpha:gpt-x');
});
await flushAsync();
// The controlled select highlights the alpha row via the composite identity.
expect(modelSelect.value).toBe('alpha:gpt-x');
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'ping');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
// The persisted/sent tuple resolves to provider alpha — NOT beta — even
// though the bare modelId 'gpt-x' exists under both providers.
expect(fake.emitted).toContainEqual({
event: 'message',
payload: {
conversationId: undefined,
content: 'ping',
provider: 'alpha',
modelId: 'gpt-x',
},
});
});
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
expect(fake.listeners.size).toBeGreaterThan(0);
+3
View File
@@ -6,6 +6,7 @@ import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
import { SessionPanel } from '@/spa/chat/session-panel';
import { ToolCallList } from '@/spa/chat/tool-call-list';
import { useChatConnection } from '@/spa/chat/use-chat-connection';
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
/** Renders a real value normally, but an honest "unavailable" label instead
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
@@ -23,6 +24,7 @@ function formatCost(value: unknown): string {
export function ChatPage(): ReactElement {
const { state, actions } = useChatConnection();
const harness = useHarnessSelection();
const hasConversation = state.conversationId !== null;
return (
@@ -86,6 +88,7 @@ export function ChatPage(): ReactElement {
streaming={state.streaming}
sending={state.sending}
hasConversation={hasConversation}
harness={harness}
/>
</div>
);
+65
View File
@@ -6,6 +6,7 @@ import type {
SlashCommandResultPayload,
SystemReloadPayload,
} from '../commands/index.js';
import type { HarnessErrorCode, HarnessSelection, HarnessTurnState } from '../harness/index.js';
export interface MessageAckPayload {
conversationId: string;
@@ -107,8 +108,70 @@ export interface AbortPayload {
conversationId: string;
}
/**
* The frozen P3 `turn:send` wire contract (Task Five; reused unchanged by Tasks 15 and 16).
* Accepts no attachments or authority-bearing fields in Slice Zero. Gateway validation
* requires a UUID conversation id, non-empty bounded content, a nested selection with exactly
* `harnessId`/`providerId`/`modelId` (each 1..255 chars), and a UUID-v4 idempotency key; it
* rejects unknown fields, top-level `provider`/`modelId`, malformed nesting, and empty values
* before any runtime dispatch.
*/
export interface HarnessTurnSendPayload {
readonly conversationId: string; // UUID; required before send
readonly content: string; // trimmed, 1..10_000 characters
readonly selection: HarnessSelection; // nested; exactly three ids
readonly idempotencyKey: string; // browser-generated UUID v4
}
/**
* The frozen `turn:ack` wire contract. Success echoes the accepted idempotency key and the
* exact requested selection tuple; failure carries only fixed/safe text and never a
* substituted effective selection or raw exception text.
*/
export type HarnessTurnAckPayload =
| {
readonly ok: true;
readonly conversationId: string;
readonly idempotencyKey: string;
readonly turnId: string;
readonly correlationId: string;
readonly state: HarnessTurnState;
readonly selection: HarnessSelection;
}
| {
readonly ok: false;
readonly conversationId?: string;
readonly idempotencyKey?: string;
readonly code: HarnessErrorCode | 'request_invalid' | 'runtime_unsupported';
readonly message: string; // fixed/safe text only
readonly retryable: boolean;
readonly correlationId: string;
/** Present only when a complete tuple was validated; always the requested tuple. */
readonly selection?: HarnessSelection;
};
/**
* The frozen browser send-protocol advertisement (Task Five; server → client only).
*
* A conversation id or a harness selection never proves that the connected Gateway actually
* handles a given wire event, so after BetterAuth authenticates a browser Socket connection the
* Gateway advertises — exactly once, targeted to that socket — which send event the client may
* use. `legacy-message` in legacy mode, `unavailable` in `pi-rpc` (including test-ready Pi
* graphs); Task Five never advertises `turn-send` (its authenticated handler lands in Task 15).
* Capability is routing information, never authorization: every server handler still enforces
* authentication, ownership, DTO, mode, and runtime checks.
*/
export type ChatSendProtocol = 'legacy-message' | 'turn-send' | 'unavailable';
export interface ChatSendCapabilityPayload {
readonly protocol: ChatSendProtocol;
/** Exact Socket.IO id for the authenticated browser connection this advertisement is bound to. */
readonly connectionId: string;
}
/** Socket.IO typed event map: server → client */
export interface ServerToClientEvents {
'chat:send-capability': (payload: ChatSendCapabilityPayload) => void;
'message:ack': (payload: MessageAckPayload) => void;
'agent:start': (payload: AgentStartPayload) => void;
'agent:end': (payload: AgentEndPayload) => void;
@@ -121,12 +184,14 @@ export interface ServerToClientEvents {
'command:result': (payload: SlashCommandResultPayload) => void;
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
'system:reload': (payload: SystemReloadPayload) => void;
'turn:ack': (payload: HarnessTurnAckPayload) => void;
error: (payload: ErrorPayload) => void;
}
/** Socket.IO typed event map: client → server */
export interface ClientToServerEvents {
message: (data: ChatMessagePayload) => void;
'turn:send': (data: HarnessTurnSendPayload) => void;
'set:thinking': (data: SetThinkingPayload) => void;
'command:execute': (data: SlashCommandPayload) => void;
'command:approve': (data: SlashCommandPayload) => void;
+4
View File
@@ -14,6 +14,10 @@ export type {
AbortPayload,
ErrorPayload,
ChatMessagePayload,
HarnessTurnSendPayload,
HarnessTurnAckPayload,
ChatSendProtocol,
ChatSendCapabilityPayload,
ServerToClientEvents,
ClientToServerEvents,
} from './events.js';