refactor(chat): route browser chat through one runtime

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
shaggy (mosaic-dev box)
2026-08-12 07:56:21 -05:00
co-authored by Claude Opus 4.8
parent 6a8ce66702
commit 633acd2d2a
29 changed files with 4417 additions and 662 deletions
@@ -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,
@@ -1,6 +1,8 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { 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,23 @@ 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 { 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 +89,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 +108,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 +117,197 @@ 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: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
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 +364,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 +434,219 @@ 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 }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,24 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { validateSync } from 'class-validator';
import { describe, expect, it, vi } from 'vitest';
import { ValidationPipe, type ArgumentMetadata } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { validateSync, type ValidationError } from 'class-validator';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../../agent/agent.service.js';
import { AuthGuard } from '../../auth/auth.guard.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../../harness/harness.tokens.js';
import type { AuthenticatedUserLike } from '../../auth/session-scope.js';
import { SendMessageDto } from '../../conversations/conversations.dto.js';
import { ChatRequestDto } from '../chat.dto.js';
import { ChatController } from '../chat.controller.js';
import { ChatGateway } from '../chat.gateway.js';
import { ChatRuntimeRouter } from '../chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../embedded-chat.runtime.js';
import { HarnessChatRuntime } from '../harness-chat.runtime.js';
import type { ChatRuntime } from '../chat-runtime.js';
import { ChatRequestDto, HarnessTurnSendDto } from '../chat.dto.js';
import { validateSocketSession } from '../chat.gateway-auth.js';
describe('Chat controller source hardening', () => {
@@ -17,6 +31,328 @@ describe('Chat controller source hardening', () => {
});
});
describe('Chat runtime routing hardening (Task Five)', () => {
it('routes /api/chat through the exclusive ChatRuntimeRouter, never the embedded AgentService', () => {
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
// pi-rpc /api/chat must resolve execution through the one runtime router and never
// reach into embedded agent execution. Legacy embedded behaviour lives behind
// EmbeddedChatRuntime, reachable only via the router in legacy mode.
expect(source).toContain('ChatRuntimeRouter');
expect(source).not.toContain('@Inject(AgentService)');
expect(source).not.toContain("from '../agent/agent.service.js'");
});
it('gateway no longer injects the embedded AgentService or RoutingEngineService', () => {
const source = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect(source).toContain('ChatRuntimeRouter');
expect(source).not.toContain('@Inject(AgentService)');
expect(source).not.toContain('@Inject(RoutingEngineService)');
});
});
describe('Harness turn:send DTO validation (Task Five, group 2 — frozen wire contract, production pipe)', () => {
// Correction #2 (Scrappy fe3e02): drive PLAIN wire payloads through the EXACT production
// validation the gateway applies to inbound bodies — the global ValidationPipe in
// apps/gateway/src/main.ts: { whitelist, forbidNonWhitelisted, transform }. This exercises
// the real plainToInstance transform, nested @Type/@ValidateNested recursion, and whitelist
// stripping — the path a turn:send actually travels — rather than a hand-built class instance
// fed to validateSync (which never runs @Type and is masked green by class-validator's
// empty-metadata unknownValue behaviour). Anti-masking: every VALUE-rule red asserts the field
// carries a REAL value constraint (not `whitelistValidation`/`unknownValue`), which the
// decorator-less stub can NEVER produce; every authority-field red asserts the forbidden field
// is rejected while a valid field is NOT — false against the stub, which over-rejects everything.
const PRODUCTION_PIPE = () =>
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
// Same production configuration, but hand back the raw ValidationError[] instead of throwing a
// BadRequestException, so the test can inspect per-field constraint keys and nested children.
const failPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errs: ValidationError[]) => errs as unknown as Error,
});
const asBody = (metatype: ArgumentMetadata['metatype']): ArgumentMetadata => ({
type: 'body',
metatype,
data: '',
});
const UUID_V4 = '11111111-1111-4111-8111-111111111111';
const validSelection = () => ({ harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' });
const validPayload = () => ({
conversationId: UUID_V4,
content: 'hello there',
selection: validSelection(),
idempotencyKey: UUID_V4,
});
// Constraint keys that mean "the field was rejected for existing", NOT "its VALUE failed a real
// rule". The decorator-less RED stub can only ever emit these (or nothing), so requiring a REAL
// value constraint on a field is unmaskable until Step Three attaches the decorators.
const NON_VALUE = new Set(['whitelistValidation', 'unknownValue']);
// Flatten the error tree to `dotted.path -> Set<constraintKey>` (parent + nested children).
const collect = (errors: ValidationError[], prefix = ''): Map<string, Set<string>> => {
const map = new Map<string, Set<string>>();
const add = (path: string, keys: Iterable<string>): void => {
const set = map.get(path) ?? new Set<string>();
for (const key of keys) set.add(key);
map.set(path, set);
};
for (const error of errors) {
const path = prefix ? `${prefix}.${error.property}` : error.property;
if (error.constraints) add(path, Object.keys(error.constraints));
if (error.children?.length) for (const [p, s] of collect(error.children, path)) add(p, s);
}
return map;
};
// Run the production pipe over a plain payload; return the ValidationError[] it raised (empty
// when the payload is accepted).
const errorsFor = async (
payload: unknown,
metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
): Promise<ValidationError[]> => {
try {
await failPipe.transform(payload, asBody(metatype));
return [];
} catch (thrown) {
return thrown as ValidationError[];
}
};
// True when `path` is rejected by a REAL value rule (IsUUID, IsNotEmpty, MaxLength, …), i.e. a
// constraint that is not a mere existence/whitelist rejection.
const hasValueConstraint = async (
payload: unknown,
path: string,
metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
): Promise<boolean> => {
const keys = collect(await errorsFor(payload, metatype)).get(path);
return keys ? [...keys].some((key) => !NON_VALUE.has(key)) : false;
};
// Paths rejected purely for existing outside the whitelist (authority / unknown-field defence).
const forbiddenFields = async (payload: unknown): Promise<string[]> => {
const out: string[] = [];
for (const [path, keys] of collect(await errorsFor(payload))) {
if (keys.has('whitelistValidation')) out.push(path);
}
return out;
};
// --- GREEN controls: prove the production pipe machinery genuinely accepts a well-formed,
// already-decorated DTO AND enforces its constraints — so the group's reds below are the
// STUB's missing decorators, not a broken harness. Both pass today. ---
it('GREEN control: the production pipe accepts a well-formed, decorated ChatRequestDto', async () => {
await expect(
PRODUCTION_PIPE().transform({ content: 'hello there' }, asBody(ChatRequestDto)),
).resolves.toBeTruthy();
});
it('GREEN control: the same production pipe rejects over-long ChatRequestDto content (engine truly enforces)', async () => {
expect(
await hasValueConstraint({ content: 'x'.repeat(10_001) }, 'content', ChatRequestDto),
).toBe(true);
});
// --- RED value-rule fence: each asserts the turn:send field is rejected by a REAL value rule.
// All fail against the decorator-less stub; they go green when Step Three adds the decorators.
// No validation implementation is permitted during RED collection. ---
it('requires a conversation id (rejects a missing conversationId)', async () => {
expect(
await hasValueConstraint({ ...validPayload(), conversationId: undefined }, 'conversationId'),
).toBe(true);
});
it('requires a UUID conversation id (rejects a non-UUID conversationId)', async () => {
expect(
await hasValueConstraint(
{ ...validPayload(), conversationId: 'not-a-uuid' },
'conversationId',
),
).toBe(true);
});
it('rejects empty / whitespace-only content (content is trimmed 1..10000)', async () => {
expect(await hasValueConstraint({ ...validPayload(), content: ' ' }, 'content')).toBe(true);
});
it('rejects content above 10000 characters', async () => {
expect(
await hasValueConstraint({ ...validPayload(), content: 'x'.repeat(10_001) }, 'content'),
).toBe(true);
});
it('requires the nested selection triple (rejects a missing selection)', async () => {
expect(await hasValueConstraint({ ...validPayload(), selection: undefined }, 'selection')).toBe(
true,
);
});
it('rejects malformed selection nesting (a non-object selection)', async () => {
expect(
await hasValueConstraint(
{ ...validPayload(), selection: 'pi/anthropic/claude' },
'selection',
),
).toBe(true);
});
it('rejects a selection with a blank harnessId (each id must be non-empty)', async () => {
const payload = {
...validPayload(),
selection: { harnessId: '', providerId: 'anthropic', modelId: 'claude' },
};
expect(await hasValueConstraint(payload, 'selection.harnessId')).toBe(true);
});
it('rejects a selection missing the modelId', async () => {
const payload = { ...validPayload(), selection: { harnessId: 'pi', providerId: 'anthropic' } };
expect(await hasValueConstraint(payload, 'selection.modelId')).toBe(true);
});
it('requires a UUID-v4 idempotency key (rejects a missing key)', async () => {
expect(
await hasValueConstraint({ ...validPayload(), idempotencyKey: undefined }, 'idempotencyKey'),
).toBe(true);
});
it('rejects a non-UUID-v4 idempotency key', async () => {
expect(
await hasValueConstraint(
{ ...validPayload(), idempotencyKey: 'not-a-key' },
'idempotencyKey',
),
).toBe(true);
});
// --- RED authority/unknown-field fence: the forbidden field is rejected while a valid field is
// NOT spuriously rejected. False against the stub (which over-rejects every field, including
// `content`); true only once Step Three whitelists the legitimate fields. ---
it('rejects a top-level authority field (provider) without flagging valid fields', async () => {
const forbidden = await forbiddenFields({ ...validPayload(), provider: 'openai' });
expect(forbidden).toContain('provider');
expect(forbidden).not.toContain('content');
});
it('rejects a top-level modelId authority field without flagging valid fields', async () => {
const forbidden = await forbiddenFields({ ...validPayload(), modelId: 'gpt-5' });
expect(forbidden).toContain('modelId');
expect(forbidden).not.toContain('content');
});
it('rejects an attachments field (not part of the frozen turn:send contract)', async () => {
const forbidden = await forbiddenFields({ ...validPayload(), attachments: [{ id: 'a1' }] });
expect(forbidden).toContain('attachments');
expect(forbidden).not.toContain('content');
});
});
describe('Chat runtime routing — behavioural /api/chat fence (Task Five, group 3)', () => {
// The router's own runtime tokens. The controller must reach chat execution ONLY through the
// router; the embedded AgentService below is the forbidden path proven untouched.
const embedded: ChatRuntime = { kind: 'embedded' };
const harness: ChatRuntime = { kind: 'harness' };
// 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;
it('handles an /api/chat turn without invoking the embedded AgentService (behavioural, zero calls)', async () => {
const calls = { getSession: 0, createSession: 0, onEvent: 0, prompt: 0 };
// A spy standing in for the forbidden embedded runtime. `createSession` rejects so today's
// controller bails immediately (never reaching the 120s agent-response wait) while still
// recording that it reached into the embedded path — the RED anchor. Under Step Three the
// router owns execution and this spy is never touched, so every counter stays 0 (GREEN).
// Any masking mutation that re-enters embedded execution flips a counter and re-reds the test.
const agentSpy = {
getSession: () => {
calls.getSession += 1;
return undefined;
},
createSession: () => {
calls.createSession += 1;
return Promise.reject(new Error('spy: embedded AgentService must not be used'));
},
onEvent: () => {
calls.onEvent += 1;
return () => {};
},
prompt: () => {
calls.prompt += 1;
return Promise.resolve();
},
};
const moduleRef = await Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: agentSpy },
{
// Provided so the Step-Three controller (which injects the router) still resolves here;
// the real, empty registry seeded with a pi adapter keeps the router pi-rpc-ready.
provide: HARNESS_REGISTRY,
useFactory: () => {
const registry = new HarnessRegistry();
registry.register({
id: 'pi',
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;
},
},
{
provide: ChatRuntimeRouter,
useFactory: (registry: HarnessRegistry) =>
new ChatRuntimeRouter(registry, boundConversationService, embedded, harness, 'pi-rpc'),
inject: [HARNESS_REGISTRY],
},
],
})
// 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();
try {
const controller = moduleRef.get(ChatController, { strict: false });
const user = { id: 'user-1' } as AuthenticatedUserLike;
try {
await controller.chat({ content: 'route me' } as ChatRequestDto, user);
} catch {
// Today: SERVICE_UNAVAILABLE from the rejecting spy. Under Step Three: the router path may
// reject on the deliberately-unbound fake conversation service. Either way the
// embedded-call counters below are the contract, not the handler's return value.
}
expect(calls).toEqual({ getSession: 0, createSession: 0, onEvent: 0, prompt: 0 });
} finally {
await moduleRef.close();
}
});
it('wires the exclusive ChatRuntimeRouter into the chat module graph (defense-in-depth source check)', () => {
const source = readFileSync(resolve('src/chat/chat.module.ts'), 'utf8');
// The controller can only inject the router if the module actually provides it. RED today:
// ChatModule provides only ChatGateway. GREEN once Step Three registers ChatRuntimeRouter.
expect(source).toContain('ChatRuntimeRouter');
});
});
describe('WebSocket session authentication', () => {
it('returns null when the handshake does not resolve to a session', async () => {
const result = await validateSocketSession(
@@ -47,6 +383,132 @@ describe('WebSocket session authentication', () => {
});
});
describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Five, both runtime modes)', () => {
// Scrappy C adjudication regression: a non-Discord service socket — modelled as a "Telegram"
// client presenting a handshake token the gateway does NOT honour and carrying no Better-Auth
// session — must be DISCONNECTED at handleConnection, never gain the `discordService` trust flag
// or any user scope, receive no manifest and no ack, and reach NEITHER the embedded runtime NOR
// the harness. This must hold in BOTH legacy and pi-rpc modes: introducing the exclusive
// ChatRuntimeRouter / pi-rpc path must not open a second, non-Discord service ingress. This is a
// GREEN control (it holds on this branch and must keep holding through Step Three); no Telegram
// production route or plugin exists or is added — the assertion is that no such surface is
// reachable. The runtime slot is fronted with a REAL, ready ChatRuntimeRouter over
// EmbeddedChatRuntime + HarnessChatRuntime so that any accidental dispatch would flip a spy
// rather than silently pass; the router is never resolved because the socket is rejected first.
let priorMode: string | undefined;
beforeEach(() => {
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
});
afterEach(() => {
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
});
// A pi-ready registry so a pi-rpc router resolves the harness cleanly at onModuleInit — modelling
// the hostile condition where the harness is live yet the non-Discord socket is still rejected.
const readyPiRegistry = (): HarnessRegistry => {
const registry = new HarnessRegistry();
registry.register({
id: 'pi',
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;
};
// Non-sentinel conversation service so pi-rpc onModuleInit resolves the harness (does not throw
// conversation_service_unavailable); its methods must never be invoked on the rejection path.
const availableConversationService = {
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;
const readyRouter = (
mode: 'legacy' | 'pi-rpc',
agentService: unknown,
harnessConversations: unknown,
): ChatRuntimeRouter => {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(harnessConversations as never);
const router = new ChatRuntimeRouter(
readyPiRegistry(),
availableConversationService,
embedded,
harness,
mode,
);
router.onModuleInit();
return router;
};
it.each(['legacy', 'pi-rpc'] as const)(
'disconnects a Telegram-shaped unauthenticated socket and dispatches to no runtime (%s mode)',
async (mode) => {
process.env['CHAT_HARNESS_RUNTIME'] = mode;
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() };
// Auth stub that resolves NO session for the Telegram socket's headers — the sole gate a
// non-Discord client must pass, and does not.
const auth = { api: { getSession: vi.fn().mockResolvedValue(null) } };
const gateway = new ChatGateway(
readyRouter(mode, agentService, harnessConversations) as never,
auth as never,
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: `telegram-raw-${mode}`,
// A non-Discord service handshake: the gateway only honours `discordServiceToken`, so this
// token is ignored, and there is no session cookie for validateSocketSession to resolve.
handshake: { auth: { telegramServiceToken: 'ignored-non-discord-token' }, headers: {} },
data: {} as Record<string, unknown>,
emit: vi.fn(),
disconnect: vi.fn(),
};
await gateway.handleConnection(client as never);
// Rejected at the door: disconnected, no trust flag, no user scope, no manifest.
expect(client.disconnect).toHaveBeenCalled();
expect(client.data.discordService).not.toBe(true);
expect(client.data.user).toBeUndefined();
expect(client.emit).not.toHaveBeenCalledWith('commands:manifest', expect.anything());
// Even if the ignored socket then attempts a message, it carries no scope, so the send path
// never begins and no runtime is dispatched.
await gateway.handleMessage(
client as never,
{
conversationId: 'Nova:telegram:chat-1',
content: 'via telegram',
} as never,
);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversations.append).not.toHaveBeenCalled();
},
);
});
describe('Chat DTO validation', () => {
it('rejects unsupported message roles', () => {
const dto = Object.assign(new SendMessageDto(), {
@@ -0,0 +1,667 @@
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,
type ChatRuntime,
type ChatRuntimeMode,
} 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 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,
Binary file not shown.
File diff suppressed because it is too large Load Diff
+49 -2
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],
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],
})
export class ChatModule {}
@@ -0,0 +1,448 @@
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 = '';
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error('Agent response timed out'));
}, REST_TURN_TIMEOUT_MS);
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, input.content, scope);
await done;
} catch (err) {
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;
const detach = this.subscribe(conversationId, scope, stream);
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,
});
if (!resolved.ok) return resolved;
const detach = this.subscribe(conversationId, scope, stream);
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 }>,
): Promise<
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
let session = this.agentService.getSession(conversationId, scope);
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 };
}
}
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,
);
this.agentService.addChannel(conversationId, stream.channelId, scope);
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,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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`.
*
* The refusal is env-driven (`resolveChatRuntimeMode(process.env)` via `CHAT_HARNESS_RUNTIME`), so
* the controller's constructor signature does not change; the mode is read at request time.
*
* RED today: the controller writes unconditionally, so the pi-rpc case both writes (repo spy > 0)
* and returns a message instead of the typed refusal — a behavioural failure, not a DI/import one.
* GREEN (HELD until the RED checkpoint independently passes) adds the pre-write mode guard.
*/
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,
};
}
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 in pi-rpc mode with a fixed typed unsupported, before any write', async () => {
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain);
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 in legacy mode (GREEN control)', async () => {
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain);
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('writes through the repository when no runtime mode is set (defaults to legacy — GREEN control)', async () => {
delete process.env['CHAT_HARNESS_RUNTIME'];
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain);
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 { resolveChatRuntimeMode } from '../chat/chat-runtime.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -26,6 +28,27 @@ 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 {
@@ -94,6 +117,12 @@ 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.
if (resolveChatRuntimeMode(process.env) === 'pi-rpc') {
throw new HarnessRuntimeWriteUnsupportedException();
}
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
+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,9 @@ 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('retains validated persisted attachments in resumed conversation history', async () => {
@@ -593,6 +654,7 @@ 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 session = {
@@ -618,8 +680,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 +730,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', () => {
+6
View File
@@ -15,6 +15,9 @@ import type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
@@ -42,6 +45,9 @@ export type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
HarnessTurnSendPayload,
MessageAckPayload,
RoutingDecisionInfo,
ServerToClientEvents,
+22 -4
View File
@@ -1,8 +1,14 @@
import { useState, type KeyboardEvent, type ReactElement } from 'react';
import type { HarnessSelection } from '@/lib/types';
import type { HarnessSelectionValue } from './use-harness-selection';
interface ComposerProps {
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
onSend: (input: {
content: string;
provider?: string;
modelId?: string;
selection: HarnessSelection;
}) => boolean;
onStop: () => void;
streaming: boolean;
/** True from local send time through server turn startup/ack and
@@ -44,11 +50,23 @@ export function Composer({
if (busy) return;
// Send is gated on a validated, persisted catalog tuple — a draft or unset
// selection can never emit, so provider/model never travel as free text.
if (!harness.canSend) return;
if (!harness.canSend || harness.persistedSelection === null) return;
const trimmed = content.trim();
if (!trimmed) return;
onSend({ content: trimmed, ...harness.projection });
setContent('');
// Pass BOTH the nested selection tuple (the turn-runtime contract) and the
// flat provider/modelId (the legacy first-send that creates the conversation)
// — both derived from the same validated persisted tuple. The hook decides
// which path applies from whether a conversation is already established.
const selection = harness.persistedSelection;
const ok = onSend({
content: trimmed,
selection,
provider: selection.providerId,
modelId: selection.modelId,
});
// Clear the input only when the send was accepted — a refused turn (e.g. a
// failed idempotency mint) must retain the user's text so it is not lost.
if (ok) setContent('');
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
@@ -21,6 +21,7 @@ vi.mock('@/lib/socket', () => ({
destroySocket: destroySocketMock,
}));
import type { HarnessSelection } from '@mosaicstack/types';
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
let fake: ReturnType<typeof createFakeChatSocket>;
@@ -33,6 +34,111 @@ function Harness(): null {
return null;
}
/**
* Task Five, Step Two (web send path) red-first support. These probe the FUTURE
* pi-rpc send contract against the CURRENT implementation, so the desired API is
* expressed here as a localized cast — production types stay untouched until Step
* Three. The reds fail on behaviour (legacy `message` emitted instead of
* `turn:send`; no nested selection; no idempotency key; void return; no
* conversation-id gating), never on a missing module or type.
*/
interface HarnessTurnSendInput {
readonly content: string;
readonly selection: HarnessSelection;
}
type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean;
function harnessSend(): HarnessSendMessage {
return latest?.actions.sendMessage as unknown as HarnessSendMessage;
}
/**
* Install a controllable `crypto.randomUUID` on the global crypto object and
* return a restore fn. Uses defineProperty on the instance so it works whether
* or not the native method is configurable (it lives on the prototype, so an own
* property simply shadows it).
*/
function installRandomUUID(fn: () => string): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: fn,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own
* `undefined` property. The native method lives on `Crypto.prototype`, so a
* bare delete of the (non-existent) own property would leave the inherited
* method visible — the shadow is what actually makes the call site see no
* secure generator. Returns a restore fn.
*/
function removeRandomUUID(): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: undefined,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
/**
* Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface
* and the FUTURE fixed idempotency/rejection notice, expressed as a localized
* read-only view over `state`. Production `ChatConnectionState` gains
* `turnReceipt` at Step Three; the cast keeps production types untouched until
* then, so a success assertion against it fails on BEHAVIOUR (no turn:ack
* handler runs), never on a missing module. `error` already exists on state.
*/
interface HarnessTurnReceiptView {
readonly idempotencyKey: string;
readonly receiptId: string;
readonly selection: HarnessSelection;
}
interface HarnessTurnStateView {
readonly turnReceipt: HarnessTurnReceiptView | null | undefined;
readonly error: string | null;
}
function harnessTurnState(): HarnessTurnStateView {
return latest?.state as unknown as HarnessTurnStateView;
}
/**
* Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op
* today (the fake iterates an empty handler set), so the group-4 reds fail
* because nothing is surfaced, not because this throws. The event name is cast
* past the compile-time `ServerToClientEvents` contract exactly as the
* `turn:send` client cast is; the typed event map lands at Step Three.
*/
function serverEmitTurnAck(payload: unknown): void {
fake.serverEmitRaw('turn:ack' as unknown as Parameters<typeof fake.serverEmitRaw>[0], payload);
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
@@ -373,6 +479,371 @@ describe('useChatConnection', () => {
});
});
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
async function establishConversation(): Promise<void> {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
}
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
const restore = installRandomUUID(() => UUID);
try {
await establishConversation();
await act(async () => {
harnessSend()({ content: 'hello', selection });
});
} finally {
restore();
}
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'hello',
selection,
idempotencyKey: UUID,
});
// The pi-rpc sender must not fall back to the embedded `message` event.
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
});
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
const gen = vi.fn(() => UUID);
const restore = installRandomUUID(gen);
try {
await establishConversation();
await act(async () => {
harnessSend()({ content: 'first', selection });
});
await act(async () => {
harnessSend()({ content: 'second', selection });
});
} finally {
restore();
}
expect(gen).toHaveBeenCalledTimes(2);
const keys = fake.emitted
.filter((e) => e.event === 'turn:send')
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
expect(keys).toEqual([UUID, UUID]);
});
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
const restore = installRandomUUID(() => UUID);
let returned: boolean | undefined;
try {
await act(async () => {
returned = harnessSend()({ content: 'too early', selection });
});
} finally {
restore();
}
expect(returned).toBe(false);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// Nothing optimistically appended when the send is refused.
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
});
it('returns true when it emits and false when the send is refused', async () => {
const restore = installRandomUUID(() => UUID);
let refusedEarly: boolean | undefined;
let acceptedAfter: boolean | undefined;
try {
await act(async () => {
refusedEarly = harnessSend()({ content: 'early', selection });
});
await establishConversation();
await act(async () => {
acceptedAfter = harnessSend()({ content: 'now', selection });
});
} finally {
restore();
}
expect(refusedEarly).toBe(false);
expect(acceptedAfter).toBe(true);
});
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
await establishConversation();
const failing = installRandomUUID(() => {
throw new Error('secure random unavailable');
});
let firstReturn: boolean | undefined;
try {
await act(async () => {
firstReturn = harnessSend()({ content: 'blocked', selection });
});
} finally {
failing();
}
expect(firstReturn).toBe(false);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
// The send lock must have been released, so a subsequent valid send works.
const restore = installRandomUUID(() => UUID);
let secondReturn: boolean | undefined;
try {
await act(async () => {
secondReturn = harnessSend()({ content: 'retry', selection });
});
} finally {
restore();
}
expect(secondReturn).toBe(true);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
});
});
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
// Establish the conversation and send one accepted turn under a controlled
// idempotency key. Returns the crypto restore fn so callers unwind it.
async function establishAndSend(): Promise<() => void> {
const restore = installRandomUUID(() => UUID);
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
await act(async () => {
harnessSend()({ content: 'hello', selection });
});
return restore;
}
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
const restore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
receiptId: 'r1',
selection,
});
});
} finally {
restore();
}
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
// only when Step Three echoes the exact tuple back into state — never a
// substituted or fabricated one.
expect(harnessTurnState().turnReceipt).toEqual({
idempotencyKey: UUID,
receiptId: 'r1',
selection,
});
});
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
const restore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
});
});
} finally {
restore();
}
// RED anchor: a rejected ack must surface a visible notice; today no
// handler runs, so state.error stays null.
expect(harnessTurnState().error).toBeTruthy();
// The raw internal exception text must never reach the browser surface.
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
// A rejection must not fabricate a success receipt tuple.
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
});
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
const firstRestore = await establishAndSend();
try {
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'cause-ALPHA adapter_unavailable',
});
});
} finally {
firstRestore();
}
const firstNotice = harnessTurnState().error;
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
// reason, must surface the identical fixed notice.
const secondRestore = installRandomUUID(() => UUID);
try {
await act(async () => {
harnessSend()({ content: 'again', selection });
});
await act(async () => {
serverEmitTurnAck({
conversationId: 'c1',
idempotencyKey: UUID,
ok: false,
code: 'runtime_unsupported',
error: 'cause-BRAVO conversation_service_unavailable',
});
});
} finally {
secondRestore();
}
const secondNotice = harnessTurnState().error;
// RED anchor: both are null today; green requires a single frozen safe
// string surfaced for both distinct internal causes.
expect(firstNotice).toBeTruthy();
expect(secondNotice).toBeTruthy();
expect(firstNotice).toBe(secondNotice);
expect(firstNotice ?? '').not.toContain('ALPHA');
expect(secondNotice ?? '').not.toContain('BRAVO');
});
});
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude',
};
const UUID_A = '11111111-1111-4111-8111-111111111111';
const UUID_B = '22222222-2222-4222-9222-222222222222';
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
async function establish(): Promise<void> {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
}
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
const keys = [UUID_A, UUID_B];
let call = 0;
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
try {
await establish();
await act(async () => {
harnessSend()({ content: 'first', selection });
});
await act(async () => {
harnessSend()({ content: 'second', selection });
});
} finally {
restore();
}
const sent = fake.emitted
.filter((e) => e.event === 'turn:send')
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
// RED anchor: current sendMessage emits the legacy `message`, so no
// turn:send keys exist at all.
expect(sent).toHaveLength(2);
expect(sent[0]).toMatch(UUID_V4);
expect(sent[1]).toMatch(UUID_V4);
expect(sent[0]).not.toBe(sent[1]);
});
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
await establish();
const restoreCrypto = removeRandomUUID();
let firstReturn: boolean | undefined;
try {
await act(async () => {
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
});
} finally {
restoreCrypto();
}
// RED anchors: a refused send returns false and surfaces a visible notice.
expect(firstReturn).toBe(false);
expect(harnessTurnState().error).toBeTruthy();
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
// The lock released synchronously (no server event needed): a later valid
// send goes through.
const restore = installRandomUUID(() => UUID_A);
let secondReturn: boolean | undefined;
try {
await act(async () => {
secondReturn = harnessSend()({ content: 'recovered', selection });
});
} finally {
restore();
}
expect(secondReturn).toBe(true);
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
});
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
// Case 1: absent.
await establish();
const restoreAbsent = removeRandomUUID();
try {
await act(async () => {
harnessSend()({ content: 'absent', selection });
});
} finally {
restoreAbsent();
}
const absentNotice = harnessTurnState().error;
// Case 2: throws with a distinctive internal message.
const failing = installRandomUUID(() => {
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
});
try {
await act(async () => {
harnessSend()({ content: 'throws', selection });
});
} finally {
failing();
}
const throwNotice = harnessTurnState().error;
// RED anchor: both are null today.
expect(absentNotice).toBeTruthy();
expect(throwNotice).toBeTruthy();
expect(absentNotice).toBe(throwNotice);
// The thrown internal detail must never reach the browser surface.
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
expect(throwNotice ?? '').not.toContain('entropy pool');
});
});
it('abort emits abort with the active conversationId', async () => {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
+120 -3
View File
@@ -12,6 +12,7 @@ import {
import {
asConversationId,
asFiniteNumber,
asHarnessSelection,
asString,
asStringArray,
isRecord,
@@ -25,6 +26,8 @@ import type {
CommandManifest,
CommandManifestPayload,
ErrorPayload,
HarnessSelection,
HarnessTurnAckPayload,
MessageAckPayload,
SessionInfoPayload,
SessionUsagePayload,
@@ -130,6 +133,34 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
* dropped. */
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
* (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
* `code`/`message`/`error` can carry adapter internals or entropy-source detail,
* so no rejection ever leaks its cause into the UI — every distinct rejection
* shows this same string. */
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';
/** Fixed, browser-safe notice surfaced when a turn is refused because the
* idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
* Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';
/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
* Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
* source is absent (not a function) or throws — the caller then refuses the turn
* rather than falling back to any non-cryptographic source (Math.random, a
* clock, or a counter would all be forgeable/collision-prone). Never throws. */
function mintIdempotencyKey(): string | null {
try {
const c: unknown = globalThis.crypto;
if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
const key = (c.randomUUID as () => unknown)();
return typeof key === 'string' && key.length > 0 ? key : null;
} catch {
return null;
}
}
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
* must be treated as a terminal startup failure: no conversation has ever been
* established yet, and a send is still pending one. Once a conversation is
@@ -236,6 +267,14 @@ export interface PendingApproval {
args?: string;
}
/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the
* server accepted this exact turn under its minted idempotency key and selection tuple. */
export interface HarnessTurnReceipt {
idempotencyKey: string;
receiptId: string;
selection: HarnessSelection;
}
export interface ChatConnectionState {
conversationId: string | null;
/** True once a message has been sent while no conversation is active yet, so the
@@ -268,6 +307,10 @@ export interface ChatConnectionState {
approvalRequestPending: boolean;
systemReload: SystemReloadPayload | null;
error: string | null;
/** Receipt from the most recently accepted harness `turn:ack`, or null before any
* turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
* untouched (a prior accepted receipt is not erased by a later rejection). */
turnReceipt: HarnessTurnReceipt | null;
messages: ChatTranscriptMessage[];
/** Monotonically increasing counter used to mint transcript message ids —
* never reset while retained messages remain, so ids stay unique across the
@@ -308,7 +351,12 @@ export interface ChatConnectionState {
}
export interface ChatConnectionActions {
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
sendMessage: (input: {
content: string;
provider?: string;
modelId?: string;
selection?: HarnessSelection;
}) => boolean;
abort: () => void;
setThinking: (level: string) => void;
executeCommand: (input: { command: string; args?: string }) => void;
@@ -341,6 +389,7 @@ const initialState: ChatConnectionState = {
approvalRequestPending: false,
systemReload: null,
error: null,
turnReceipt: null,
messages: [],
messageSeq: 0,
toolSeq: 0,
@@ -361,7 +410,9 @@ type Action =
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
| { type: 'server/system:reload'; payload: SystemReloadPayload }
| { type: 'server/error'; payload: ErrorPayload }
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
| { type: 'local/send'; content: string }
| { type: 'local/turn-idempotency-unavailable' }
| { type: 'local/approve-request'; command: string; args?: string }
| { type: 'local/consume-approval' }
| { type: 'local/approval-saturated' }
@@ -778,6 +829,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
};
}
case 'server/turn:ack': {
// The harness runtime's turn acknowledgement. The success shape carries a
// receipt id + minted idempotencyKey + echoed selection; the failure shape
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
// top-of-reducer guard already rejected a non-object payload).
const record = action.payload as Record<string, unknown>;
if (record.ok === false) {
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
// server `message`/`error`/`code`, which can carry adapter internals — and
// does not disturb any previously accepted receipt.
return { ...state, error: TURN_REJECTED_NOTICE };
}
const idempotencyKey = asString(record.idempotencyKey);
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
const receiptId = asString(record.receiptId) || asString(record.turnId);
const selection = asHarnessSelection(record.selection);
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
// A malformed success frame is ignored outright rather than recorded as a
// half-populated receipt.
return state;
}
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
}
case 'local/send': {
const message: ChatTranscriptMessage = {
// Sourced from the reducer-owned `messageSeq` counter — see the
@@ -802,6 +877,14 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
};
}
case 'local/turn-idempotency-unavailable': {
// The idempotency-key mint failed closed (crypto.randomUUID absent or
// throwing), so the turn was refused before emit. Surface a FIXED notice —
// never the underlying thrown message, which can leak entropy-source
// internals.
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
}
case 'local/disconnect': {
// A transient socket disconnect must not leave the UI stuck waiting on
// a turn/approval/send that will never resolve on this connection.
@@ -913,6 +996,8 @@ export function useChatConnection(): ChatConnectionValue {
const onError = (payload: ErrorPayload): void => {
dispatch({ type: 'server/error', payload });
};
const onTurnAck = (payload: HarnessTurnAckPayload): void =>
dispatch({ type: 'server/turn:ack', payload });
const onDisconnect = (): void => {
dispatch({ type: 'local/disconnect' });
};
@@ -930,6 +1015,7 @@ export function useChatConnection(): ChatConnectionValue {
socket.on('command:approval', onCommandApproval);
socket.on('system:reload', onSystemReload);
socket.on('error', onError);
socket.on('turn:ack', onTurnAck);
socket.on('disconnect', onDisconnect);
if (!socket.connected) {
@@ -950,14 +1036,44 @@ export function useChatConnection(): ChatConnectionValue {
socket.off('command:approval', onCommandApproval);
socket.off('system:reload', onSystemReload);
socket.off('error', onError);
socket.off('turn:ack', onTurnAck);
socket.off('disconnect', onDisconnect);
destroySocket();
};
}, []);
const actions: ChatConnectionActions = {
sendMessage: ({ content, provider, modelId }) => {
if (sendLockRef.current || state.streaming || state.sending) return;
sendMessage: ({ content, provider, modelId, selection }) => {
// Turn-runtime path: a selection tuple against an already-established
// conversation routes through the exclusive `turn:send` contract. It is
// lock-independent by design — it does not engage the send lock, does not
// dispatch `local/send` (no optimistic append), and mints exactly one
// idempotency key per accepted turn. A failed mint fails the turn closed.
if (selection != null && state.conversationId !== null) {
const idempotencyKey = mintIdempotencyKey();
if (idempotencyKey === null) {
dispatch({ type: 'local/turn-idempotency-unavailable' });
return false;
}
const socket = getSocket();
if (!socket.connected) socket.connect();
socket.emit('turn:send', {
conversationId: state.conversationId,
content,
selection,
idempotencyKey,
});
return true;
}
// A selection tuple with no active conversation and no flat provider/model
// is a bare harness call that cannot create a conversation — refuse it,
// emitting nothing and appending nothing.
if (selection != null && provider === undefined && modelId === undefined) {
return false;
}
// Legacy `message` path (first send that creates the conversation, and every
// pre-turn-runtime send) — unchanged behavior, now reporting acceptance.
if (sendLockRef.current || state.streaming || state.sending) return false;
sendLockRef.current = true;
const socket = getSocket();
if (!socket.connected) socket.connect();
@@ -968,6 +1084,7 @@ export function useChatConnection(): ChatConnectionValue {
provider,
modelId,
});
return true;
},
abort: () => {
@@ -226,7 +226,10 @@ describe('useHarnessSelection', () => {
modelId: 'gpt-5',
});
expect(value().canSend).toBe(true);
expect(value().projection).toEqual({ provider: 'openai', modelId: 'gpt-5' });
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
// harnessId must never be dropped on the way to the wire.
expect('projection' in value()).toBe(false);
});
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
@@ -386,7 +389,8 @@ describe('useHarnessSelection', () => {
providerId: 'anthropic',
modelId: 'claude',
});
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
});
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
@@ -420,7 +424,8 @@ describe('useHarnessSelection', () => {
});
await flush();
expect(value().canSend).toBe(true);
expect(value().projection).toEqual({ provider: 'anthropic', modelId: 'claude' });
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
expect('projection' in value()).toBe(false);
});
it('never requests any /api/providers* endpoint across the whole flow', async () => {
@@ -42,10 +42,6 @@ export interface HarnessSelectionValue {
* resolves the composite option identity to the real entry and passes both
* ids, so a bare model id is never combined with ambient provider state. */
selectModel: (providerId: string, modelId: string) => void;
/** The compatibility `{provider, modelId}` projection for the legacy socket
* send path — derived ONLY from the validated persisted tuple, never from any
* free-text or unpersisted draft. Empty when nothing is sendable. */
projection: { provider?: string; modelId?: string };
}
/** A tuple is a currently-usable catalog option only when the catalog holds a
@@ -193,9 +189,6 @@ export function useHarnessSelection(): HarnessSelectionValue {
!catalogUnavailable &&
tuplesEqual(draft, persistedSelection) &&
isAvailableInCatalog(persistedSelection, catalog);
const projection: { provider?: string; modelId?: string } = canSend
? { provider: persistedSelection.providerId, modelId: persistedSelection.modelId }
: {};
return {
harnesses,
@@ -211,6 +204,5 @@ export function useHarnessSelection(): HarnessSelectionValue {
selectHarness,
selectProvider,
selectModel,
projection,
};
}
+173
View File
@@ -108,6 +108,33 @@ async function flushAsync(times = 5): Promise<void> {
}
}
/** Deterministic idempotency key for the Task Five red-first page send test. */
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
* defineProperty on the crypto instance so it works whether or not the native
* method is configurable (it lives on the prototype; an own property shadows it). */
function installRandomUUID(fn: () => string): () => void {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (!g.crypto) {
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
}
const cryptoObj = g.crypto as { randomUUID?: () => string };
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
Object.defineProperty(cryptoObj, 'randomUUID', {
configurable: true,
writable: true,
value: fn,
});
return () => {
if (original) {
Object.defineProperty(cryptoObj, 'randomUUID', original);
} else {
Reflect.deleteProperty(cryptoObj, 'randomUUID');
}
};
}
let fake: ReturnType<typeof createFakeChatSocket>;
let root: Root | null;
let container: HTMLElement;
@@ -571,6 +598,152 @@ describe('ChatPage', () => {
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
});
it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => {
const restore = installRandomUUID(() => PAGE_UUID);
try {
// Send is disabled without an active conversation — establish one first.
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'hello there');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
} finally {
restore();
}
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'hello there',
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
idempotencyKey: PAGE_UUID,
});
// The pi-rpc page send must not emit the embedded `message` event, and must
// never send a flat {provider, modelId} that drops the harnessId.
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
});
it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => {
const failing = installRandomUUID(() => {
throw new Error('secure random unavailable');
});
try {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'keep me');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
// No wire traffic: neither the harness turn nor the legacy message.
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// The composer retained its content — it clears ONLY on a successful send,
// so the user can retry without retyping.
expect(textarea.value).toBe('keep me');
// A visible, safe notice explains why nothing was sent.
expect(container.querySelector('[role="alert"]')).toBeTruthy();
} finally {
failing();
}
});
it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => {
const restore = installRandomUUID(() => PAGE_UUID);
try {
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'ship it');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
// On a successful send the composer clears.
expect(textarea.value).toBe('');
} finally {
restore();
}
});
it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => {
const restore = installRandomUUID(() => PAGE_UUID);
try {
// Change the selection away from the mount default and let it persist.
const providerSelect = container.querySelector(
'select[aria-label="Provider"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(providerSelect, 'anthropic');
});
const modelSelect = container.querySelector(
'select[aria-label="Model"]',
) as HTMLSelectElement;
await act(async () => {
selectValue(modelSelect, 'anthropic:claude');
});
await flushAsync();
await act(async () => {
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
});
const textarea = container.querySelector(
'textarea[aria-label="Message"]',
) as HTMLTextAreaElement;
await act(async () => {
setValue(textarea, 'routed');
});
await act(async () => {
textarea.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
);
});
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
expect(sends).toHaveLength(1);
// The nested tuple reflects the CURRENTLY persisted selection, not the
// mount default {openai, gpt-5}, and never flat provider/model fields.
expect(sends[0]?.payload).toEqual({
conversationId: 'c1',
content: 'routed',
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
idempotencyKey: PAGE_UUID,
});
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
} finally {
restore();
}
});
it('disables send until a selection has persisted — no send with an unset selection', async () => {
await remountWithFetch(harnessFetch(null));
+45
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,6 +108,48 @@ 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;
};
/** Socket.IO typed event map: server → client */
export interface ServerToClientEvents {
'message:ack': (payload: MessageAckPayload) => void;
@@ -121,12 +164,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;
+2
View File
@@ -14,6 +14,8 @@ export type {
AbortPayload,
ErrorPayload,
ChatMessagePayload,
HarnessTurnSendPayload,
HarnessTurnAckPayload,
ServerToClientEvents,
ClientToServerEvents,
} from './events.js';