From 633acd2d2a06a6155229fc2c94beaa9ac22d738a Mon Sep 17 00:00:00 2001 From: "shaggy (mosaic-dev box)" Date: Wed, 12 Aug 2026 07:56:21 -0500 Subject: [PATCH] refactor(chat): route browser chat through one runtime Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St --- .../tess-cross-surface.integration.test.ts | 21 +- .../agent/__tests__/session-ownership.test.ts | 570 ++++++++++-- .../src/chat/__tests__/chat-security.test.ts | 468 +++++++++- .../src/chat/chat-runtime-router.spec.ts | 667 ++++++++++++++ apps/gateway/src/chat/chat-runtime-router.ts | 173 ++++ apps/gateway/src/chat/chat-runtime.ts | 273 ++++++ apps/gateway/src/chat/chat.controller.ts | 95 +- apps/gateway/src/chat/chat.dto.ts | 64 +- .../chat.gateway-command-approval.spec.ts | 21 +- .../src/chat/chat.gateway-redaction.spec.ts | Bin 7269 -> 7430 bytes apps/gateway/src/chat/chat.gateway.ts | 846 ++++++++---------- apps/gateway/src/chat/chat.module.ts | 51 +- .../gateway/src/chat/embedded-chat.runtime.ts | 448 ++++++++++ .../src/chat/harness-chat.runtime.spec.ts | 170 ++++ apps/gateway/src/chat/harness-chat.runtime.ts | 47 + .../conversations-harness-fence.spec.ts | 97 ++ .../conversations/conversations.controller.ts | 29 + apps/gateway/src/harness/harness.module.ts | 13 +- apps/gateway/src/harness/harness.tokens.ts | 34 + .../plugin/discord-ingress.security.spec.ts | 127 ++- apps/web/src/lib/chat-contract.ts | 6 + apps/web/src/spa/chat/composer.tsx | 26 +- .../src/spa/chat/use-chat-connection.spec.tsx | 471 ++++++++++ apps/web/src/spa/chat/use-chat-connection.ts | 123 ++- .../spa/chat/use-harness-selection.spec.tsx | 11 +- .../web/src/spa/chat/use-harness-selection.ts | 8 - apps/web/src/spa/pages/chat.spec.tsx | 173 ++++ packages/types/src/chat/events.ts | 45 + packages/types/src/chat/index.ts | 2 + 29 files changed, 4417 insertions(+), 662 deletions(-) create mode 100644 apps/gateway/src/chat/chat-runtime-router.spec.ts create mode 100644 apps/gateway/src/chat/chat-runtime-router.ts create mode 100644 apps/gateway/src/chat/chat-runtime.ts create mode 100644 apps/gateway/src/chat/embedded-chat.runtime.ts create mode 100644 apps/gateway/src/chat/harness-chat.runtime.spec.ts create mode 100644 apps/gateway/src/chat/harness-chat.runtime.ts create mode 100644 apps/gateway/src/conversations/conversations-harness-fence.spec.ts diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index 22abe32c..0d7916f3 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -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(); 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, diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts index 4fc0a284..ec297c34 100644 --- a/apps/gateway/src/agent/__tests__/session-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -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; + +/** + * 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('')` 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 { + 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 { + 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(); }); }); diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 45bd1f71..e455e767 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -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` (parent + nested children). + const collect = (errors: ValidationError[], prefix = ''): Map> => { + const map = new Map>(); + const add = (path: string, keys: Iterable): void => { + const set = map.get(path) ?? new Set(); + 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 => { + 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 => { + 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 => { + 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, + 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(), { diff --git a/apps/gateway/src/chat/chat-runtime-router.spec.ts b/apps/gateway/src/chat/chat-runtime-router.spec.ts new file mode 100644 index 00000000..9c25e937 --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-router.spec.ts @@ -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 { + 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(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 }): Promise => + 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(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 { + 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 => + 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(HARNESS_REGISTRY, { strict: false }); + expect(registry).toBeInstanceOf(HarnessRegistry); + expect(registry.list()).toHaveLength(0); + + const service = moduleRef.get( + 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 => {} }, + execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }), + select: () => ({ + from: () => ({ + where: async (): Promise> => [{ count: 1 }], + }), + }), + insert: () => ({ values: async (): Promise => {} }), + }; + const fakeDbHandle = { db: fakeDb, close: async (): Promise => {} }; + const fakeStorageAdapter = { + name: 'fake', + migrate: async (): Promise => {}, + close: async (): Promise => {}, + }; + // Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch. + const fakeProviderService = { + onModuleInit: async (): Promise => {}, + 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; + + 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'); + }); +}); diff --git a/apps/gateway/src/chat/chat-runtime-router.ts b/apps/gateway/src/chat/chat-runtime-router.ts new file mode 100644 index 00000000..647947ed --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-router.ts @@ -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> + > { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.completeLegacyRestTurn(context, input); + } + + prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise> { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.prepareLegacySocketTurn(context, input, stream); + } + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult { + if (this.mode === 'pi-rpc') { + return RUNTIME_UNSUPPORTED; + } + return this.embeddedPort.setLegacyThinking(context, level); + } + + abortLegacyTurn(context: OwnedConversationContext): Promise> { + if (this.mode === 'pi-rpc') { + return Promise.resolve(RUNTIME_UNSUPPORTED); + } + return this.embeddedPort.abortLegacyTurn(context); + } + + applyLegacyModelOverride( + context: OwnedConversationContext, + modelId: string, + ): LegacyRuntimeResult { + if (this.mode === 'pi-rpc') { + return RUNTIME_UNSUPPORTED; + } + return this.embeddedPort.applyLegacyModelOverride(context, modelId); + } + + readLegacySessionPresentation( + context: OwnedConversationContext, + ): LegacyRuntimeResult { + 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> { + return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream); + } +} diff --git a/apps/gateway/src/chat/chat-runtime.ts b/apps/gateway/src/chat/chat-runtime.ts new file mode 100644 index 00000000..7300091a --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime.ts @@ -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 = 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 = + | { 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>; + /** Idempotent, non-throwing. Removes listener and channel, including partial setup. */ + dispose(): Promise; +} + +/** + * 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>; + dispose(): Promise; +} + +/** Server-owned egress projection the runtime pushes normalized events into. */ +export interface LegacyRuntimeStream { + /** Server-derived, e.g. `websocket:`. 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> + >; + + prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise>; + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult; + + abortLegacyTurn(context: OwnedConversationContext): Promise>; + + applyLegacyModelOverride( + context: OwnedConversationContext, + modelId: string, + ): LegacyRuntimeResult; + + readLegacySessionPresentation( + context: OwnedConversationContext, + ): LegacyRuntimeResult; + + dispatchVerifiedDiscordIngress( + context: VerifiedDiscordIngressContext, + stream: LegacyRuntimeStream, + ): Promise>; +} + +/** + * 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 { + return { ...fields } as unknown as VerifiedDiscordIngressContext; +} diff --git a/apps/gateway/src/chat/chat.controller.ts b/apps/gateway/src/chat/chat.controller.ts index 7cd0baba..a5d2c434 100644 --- a/apps/gateway/src/chat/chat.controller.ts +++ b/apps/gateway/src/chat/chat.controller.ts @@ -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((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); + } } } diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 9bd35867..a5cba53c 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -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; +} diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts index ac297590..2d1364ef 100644 --- a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -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; createApproval: ReturnType; }): ChatGateway { return new ChatGateway( - {} as never, + failIfUsedChatRuntimeRouter() as never, {} as never, {} as never, {} as never, diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index f43dce34ce9fb4753894f23870ae8a0f5592afcf..4db4b21580ea9cbce6deecb32bc4a4cca978c814 100644 GIT binary patch delta 1207 zcmZuw&rcIU6t+ba3zQ!bQ1Qo$m(r$C3swH)aZ>F%_<=`z#I%oZw=8WTYe zChGVHn0WHMdh+hYe?YD#-Ze4tsBgB5g1U!2%)Ix#?|t7lU#53vp1p15GMNNqV3o)k z6re(xMPck_78aL_#k*i}rUduWGoTy_g4)DVE@x2YKC_8vf-9je=(uSgF959yDrK%t zg>2Z|>0(BQ8E{uwpwXoLW!j|Am zQl*h(Q3u~(i0XO!^*P0BlqH~*K`nXBENXT%b8$(;sJP6P4)P4apiHb9pzwx8Nm8Xe zYL@^RGBp7!9M92-$StS{v`vCxdC5@UaFv5>%S50sRjO9noX0&XTUe(2a;SP;@P;9A z*y=jLW(0ScP3shYOaZSPk^?O>V+?s)xDC|VKvapM8>HEuZVnEJ9}&5#9$sL0G^OcXlk zGK?s<{V;>GXwrTzB8L_CEeAQfXsP6pPN6CZ0E7?KWBh<{1~1xj3Gx2-C=P}E?~LO{ zVIi#gcRui}~G(PPl(I?e~*Zo-sC5z zE*Gv`oraWV$;Uyl>qFQ$7&V4@gA{D1XEo`cG@VDy)r;<7T~F@lV%Lc7>6;6Fbl-{T zPyIdJ$kBYJMnCOE(8SU|=eLp<^w0j?;A`@AIBX;uwwam`7#5<&rq}Y~CX zppdvoN@`9?qLqSradv!iVqRiVrMg1x&vijt`(R@W<*QDLa#zyuPC)3HL*m)BHqx_SQBEy<^Z93 z%!D=Bnq%VT2+>wD!b2WrUs`4nFcTD|78d|>Ln?B#sZ2HyS0+8ArC}x)rvfur3X0L2 b>%>EuG4jXc(^8i>z*#9iHE;7`>10L#auG8A diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 188f6cd0..60665ca0 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -11,7 +11,6 @@ import { MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { verifyDiscordIngressEnvelope, parseDiscordInteractionBindings, @@ -33,7 +32,7 @@ import type { AbortPayload, ChannelAttachmentDto, } from '@mosaicstack/types'; -import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; +import type { ConversationHistoryMessage } from '../agent/agent.service.js'; import { RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderService, @@ -51,6 +50,15 @@ import { CommandRegistryService } from '../commands/command-registry.service.js' import { CommandExecutorService } from '../commands/command-executor.service.js'; import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { + ownConversation, + verifyDiscordIngress, + type LegacyRuntimeEvent, + type LegacyRuntimeStream, + type LegacySocketTurnLease, + type VerifiedDiscordTurnLease, +} from './chat-runtime.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; @@ -60,7 +68,10 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; interface ClientSession { clientId: string; conversationId: string; - cleanup: () => void; + /** Server-derived egress channel id (`websocket:`) this turn streams over. */ + channelId: string; + /** The prepared runtime turn; disposing it removes the listener and channel. */ + lease: LegacySocketTurnLease | VerifiedDiscordTurnLease; /** Accumulated assistant response text for the current turn. */ assistantText: string; /** Tool calls observed during the current turn. */ @@ -69,8 +80,6 @@ interface ClientSession { pendingToolCalls: Map; /** Server-derived owner/tenant scope for this socket's conversation attachment. */ scope: ActorTenantScope; - /** Last routing decision made for this session (M4-008) */ - lastRoutingDecision?: RoutingDecisionInfo; } /** @@ -218,12 +227,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( - @Inject(AgentService) private readonly agentService: AgentService, + private readonly runtime: ChatRuntimeRouter, @Inject(AUTH) private readonly auth: Auth, @Inject(BRAIN) private readonly brain: Brain, @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, - @Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService, + // Vestigial arity-only slot: the router is the sole execution authority and the gateway + // never routes. The union type erases to `Object`, so with `@Optional()` and no `@Inject` + // this resolves to `null` in every graph (production and test) and is never invoked. + @Optional() private readonly routingEngine: RoutingEngineService | null = null, @Optional() @Inject(CommandAuthorizationService) private readonly commandAuthorization: CommandAuthorizationService | null = null, @@ -267,12 +279,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.log(`Client disconnected: ${client.id}`); for (const [key, session] of this.clientSessions) { if (session.clientId !== client.id) continue; - session.cleanup(); - this.agentService.removeChannel( - session.conversationId, - `websocket:${client.id}`, - session.scope, - ); + // The lease owns listener + channel teardown; dispose is idempotent and non-throwing. + void session.lease.dispose(); this.clientSessions.delete(key); this.textEgressBuffers.delete(key); this.thinkingEgressBuffers.delete(key); @@ -304,284 +312,87 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() rawData: unknown, ): Promise { - let discordIngress: DiscordIngressPayload | null = null; - let data: ChatSocketMessageDto; + // Verified-Discord ingress and browser turns are distinct trust surfaces: the service flag + // is set only after handshake-token auth at handleConnection. A forged envelope from a + // non-service socket falls through to the browser path, where it is rejected as malformed. if (client.data.discordService) { - if (!isDiscordIngressEnvelope(rawData)) { - this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); - return; - } - discordIngress = this.resolveDiscordIngress(client, rawData); - if (!discordIngress) return; - data = { - conversationId: discordIngress.conversationId, - content: discordIngress.content, - ...(discordIngress.attachments - ? { - attachments: discordIngress.attachments.map( - (attachment): ChannelAttachmentDto => ({ - id: attachment.id, - name: attachment.name, - url: attachment.url, - mimeType: attachment.contentType, - ...(attachment.sizeBytes !== undefined - ? { sizeBytes: attachment.sizeBytes } - : {}), - }), - ), - } - : {}), - }; - } else { - if (!isChatSocketMessage(rawData)) { - this.logger.warn(`Rejected malformed chat message from ${client.id}`); - return; - } - data = rawData; - } - const conversationId = data.conversationId ?? uuid(); - const clientConversationKey = this.clientConversationKey(client, conversationId); - const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; - if (discordIngress && !discordServiceUserId) { - this.logger.warn( - `Rejected Discord ingress without configured service owner from ${client.id}`, - ); + await this.handleVerifiedDiscordSend(client, rawData); return; } - const scope = discordIngress - ? { - userId: discordServiceUserId!, - tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, - } - : this.getClientScope(client); + await this.handleBrowserSend(client, rawData); + } + + private async handleBrowserSend(client: Socket, rawData: unknown): Promise { + // Fail a legacy browser turn closed under pi-rpc BEFORE parsing the payload — never fall back. + if (this.runtime.runtimeMode === 'pi-rpc') { + const conversationId = + typeof rawData === 'object' && + rawData !== null && + typeof (rawData as { conversationId?: unknown }).conversationId === 'string' + ? (rawData as { conversationId: string }).conversationId + : undefined; + client.emit('error', { + conversationId, + code: 'runtime_unsupported', + retryable: false, + error: 'Browser chat is not available on this deployment.', + }); + return; + } + + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + const data = rawData; + const conversationId = data.conversationId ?? uuid(); + const scope = this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } - const userId = scope.userId; - const correlationId = discordIngress?.correlationId; - this.logger.log( - `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, + this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + + // Dispose any prior turn on this exact channel BEFORE preparing the next: prepare re-adds the + // same server-derived channel id, so disposing after would tear down the new subscription. + const key = this.clientConversationKey(client, conversationId); + await this.disposeExistingSession(key); + + const stream: LegacyRuntimeStream = { + channelId: `websocket:${client.id}`, + onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), + }; + + const prepared = await this.runtime.prepareLegacySocketTurn( + ownConversation(conversationId, scope), + { + content: data.content, + ...(data.provider ? { provider: data.provider } : {}), + ...(data.modelId ? { modelId: data.modelId } : {}), + ...(data.agentId ? { agentId: data.agentId } : {}), + ...(data.attachments ? { attachments: data.attachments } : {}), + }, + stream, ); - - // Ensure agent session exists for this conversation - let sessionRoutingDecision: RoutingDecisionInfo | undefined; - try { - let agentSession = this.agentService.getSession(conversationId, scope); - if (!agentSession) { - // When resuming an existing conversation, load prior messages to inject as context (M1-004) - const conversationHistory = await this.loadConversationHistory(conversationId, userId); - - // M5-004: Check if there's an existing sessionId bound to this conversation - let existingSessionId: string | undefined; - if (userId) { - existingSessionId = await this.getConversationSessionId(conversationId, userId); - if (existingSessionId) { - this.logger.log( - `Resuming existing sessionId=${existingSessionId} for conversation=${conversationId}`, - ); - } - } - - // Determine provider/model via routing engine or per-session /model override (M4-012 / M4-007) - let resolvedProvider = data.provider; - let resolvedModelId = data.modelId; - - const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope)); - if (modelOverride) { - // /model override bypasses routing engine (M4-007) - resolvedModelId = modelOverride; - this.logger.log( - `Using /model override "${modelOverride}" for conversation=${conversationId}`, - ); - } else if (!resolvedProvider && !resolvedModelId && !discordIngress) { - // No explicit provider/model from client — use routing engine (M4-012) - try { - const routingDecision = await this.routingEngine.resolve(data.content, userId); - resolvedProvider = routingDecision.provider; - resolvedModelId = routingDecision.model; - sessionRoutingDecision = { - model: routingDecision.model, - provider: routingDecision.provider, - ruleName: routingDecision.ruleName, - reason: routingDecision.reason, - }; - this.logger.log( - `Routing decision for conversation=${conversationId}: ${routingDecision.provider}/${routingDecision.model} (rule="${routingDecision.ruleName}")`, - ); - } catch (routingErr) { - this.logger.warn( - `Routing engine failed for conversation=${conversationId}, using defaults`, - routingErr instanceof Error ? routingErr.message : String(routingErr), - ); - } - } - - let resolvedAgentConfigId = data.agentId; - if (discordIngress) { - const binding = this.discordBindingFor(discordIngress, 'send'); - const agentConfig = binding - ? await this.brain.agents.findById(binding.agentConfigId) - : undefined; - if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) { - throw new Error('Configured Discord logical agent is not provisioned'); - } - resolvedAgentConfigId = agentConfig.id; - } - - // M5-004: Use existingSessionId as sessionId when available (session reuse) - const sessionIdToCreate = existingSessionId ?? conversationId; - agentSession = await this.agentService.createSession(sessionIdToCreate, { - provider: resolvedProvider, - modelId: resolvedModelId, - agentConfigId: resolvedAgentConfigId, - userId, - tenantId: scope.tenantId, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, - }); - - if (conversationHistory.length > 0) { - this.logger.log( - `Loaded ${conversationHistory.length} prior messages for conversation=${conversationId}`, - ); - } - } - } catch (err) { - this.logger.error( - `Session creation failed for client=${client.id}, conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); + if (!prepared.ok) { client.emit('error', { conversationId, + code: prepared.code, + retryable: prepared.retryable, error: 'Failed to start agent session. Please try again.', }); return; } - // Ensure conversation record exists in the DB before persisting messages - // M5-004: Also bind the sessionId to the conversation record - if (userId) { - await this.ensureConversation(conversationId, userId); - await this.bindSessionToConversation(conversationId, userId, conversationId); - } + this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); + await this.persistUserMessage(conversationId, scope.userId, data.content, data.attachments); - // M5-007: Count the user message - this.agentService.recordMessage(conversationId); + client.emit('session:info', { conversationId, ...prepared.value.presentation }); + client.emit('message:ack', { conversationId, messageId: uuid() }); - // Persist the user message - if (userId) { - try { - await this.brain.conversations.addMessage( - { - conversationId, - role: 'user', - content: redactSensitiveContent(data.content).content, - metadata: { - timestamp: new Date().toISOString(), - ...(correlationId - ? { - correlationId, - discordMessageId: discordIngress?.messageId, - discordUserId: discordIngress?.userId, - } - : {}), - ...(data.attachments && data.attachments.length > 0 - ? { - channelAttachments: data.attachments.map( - (attachment): ChannelAttachmentDto => ({ - ...attachment, - name: redactSensitiveContent(attachment.name).content, - url: redactSensitiveContent(attachment.url).content, - }), - ), - } - : {}), - classifications: redactSensitiveContent(data.content).classifications, - }, - }, - userId, - ); - } catch (err) { - this.logger.error( - `Failed to persist user message for conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - } - } - - // Always clean up previous listener to prevent leak - const existing = this.clientSessions.get(clientConversationKey); - if (existing) { - existing.cleanup(); - } - - // Subscribe to agent events and relay to client - const cleanup = this.agentService.onEvent( - conversationId, - (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }, - scope, - ); - - // Preserve routing decision from the existing client session if we didn't get a new one - const prevClientSession = this.clientSessions.get(clientConversationKey); - const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; - - this.clientSessions.set(clientConversationKey, { - clientId: client.id, - conversationId, - cleanup, - assistantText: '', - toolCalls: [], - pendingToolCalls: new Map(), - scope, - lastRoutingDecision: routingDecisionToStore, - }); - - // Track channel connection - this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope); - - // Send session info so the client knows the model/provider (M4-008: include routing decision) - // Include agentName when a named agent config is active (M5-001) - { - const agentSession = this.agentService.getSession(conversationId, scope); - if (agentSession) { - const piSession = agentSession.piSession; - client.emit('session:info', { - conversationId, - provider: agentSession.provider, - modelId: agentSession.modelId, - thinkingLevel: piSession.thinkingLevel, - availableThinkingLevels: piSession.getAvailableThinkingLevels(), - ...(agentSession.agentName ? { agentName: agentSession.agentName } : {}), - ...(routingDecisionToStore ? { routingDecision: routingDecisionToStore } : {}), - }); - } - } - - // Send acknowledgment - client.emit('message:ack', { - conversationId, - messageId: uuid(), - ...(correlationId - ? { - correlationId, - discordMessageId: discordIngress?.messageId, - discordUserId: discordIngress?.userId, - } - : {}), - }); - - // Dispatch to agent - try { - await this.agentService.prompt(conversationId, data.content, scope, data.attachments); - } catch (err) { - this.logger.error( - `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); + const dispatched = await prepared.value.dispatch(); + if (!dispatched.ok) { client.emit('error', { conversationId, error: 'The agent failed to process your message. Please try again.', @@ -589,6 +400,177 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private async handleVerifiedDiscordSend(client: Socket, rawData: unknown): Promise { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + const ingress = this.resolveDiscordIngress(client, rawData); + if (!ingress) return; + + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (!discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const binding = this.discordBindingFor(ingress, 'send'); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return; + } + + const scope: ActorTenantScope = { + userId: discordServiceUserId, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId, + }; + const conversationId = ingress.conversationId; + const attachments = ingress.attachments?.map( + (attachment): ChannelAttachmentDto => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), + }), + ); + + this.logger.log( + `Message from ${client.id} in conversation ${conversationId} correlation=${ingress.correlationId}`, + ); + + const key = this.clientConversationKey(client, conversationId); + await this.disposeExistingSession(key); + + const stream: LegacyRuntimeStream = { + channelId: `websocket:${client.id}`, + onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), + }; + + // The configured agent comes from the verified binding, never from a brain lookup: the + // embedded runtime rechecks scope and mints/reuses the session under this exact identity. + const context = verifyDiscordIngress({ + conversationId, + scope, + configuredAgent: { agentConfigId: binding.agentConfigId, instanceId: binding.instanceId }, + content: ingress.content, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }); + + const prepared = await this.runtime.dispatchVerifiedDiscordIngress(context, stream); + if (!prepared.ok) { + client.emit('error', { + conversationId, + code: prepared.code, + retryable: prepared.retryable, + error: 'Failed to start agent session. Please try again.', + }); + return; + } + + this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); + await this.persistUserMessage(conversationId, scope.userId, ingress.content, attachments, { + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }); + + client.emit('session:info', { conversationId, ...prepared.value.presentation }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }); + + const dispatched = await prepared.value.dispatch(); + if (!dispatched.ok) { + client.emit('error', { + conversationId, + error: 'The agent failed to process your message. Please try again.', + }); + } + } + + private registerClientSession( + client: Socket, + conversationId: string, + channelId: string, + lease: LegacySocketTurnLease | VerifiedDiscordTurnLease, + scope: ActorTenantScope, + ): void { + this.clientSessions.set(this.clientConversationKey(client, conversationId), { + clientId: client.id, + conversationId, + channelId, + lease, + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope, + }); + } + + private async disposeExistingSession(key: string): Promise { + const existing = this.clientSessions.get(key); + if (!existing) return; + await existing.lease.dispose(); + this.clientSessions.delete(key); + } + + private async persistUserMessage( + conversationId: string, + userId: string | undefined, + content: string, + attachments: readonly ChannelAttachmentDto[] | undefined, + discord?: { correlationId: string; discordMessageId: string; discordUserId: string }, + ): Promise { + if (!userId) return; + await this.ensureConversation(conversationId, userId); + try { + await this.brain.conversations.addMessage( + { + conversationId, + role: 'user', + content: redactSensitiveContent(content).content, + metadata: { + timestamp: new Date().toISOString(), + ...(discord + ? { + correlationId: discord.correlationId, + discordMessageId: discord.discordMessageId, + discordUserId: discord.discordUserId, + } + : {}), + ...(attachments && attachments.length > 0 + ? { + channelAttachments: attachments.map( + (attachment): ChannelAttachmentDto => ({ + ...attachment, + name: redactSensitiveContent(attachment.name).content, + url: redactSensitiveContent(attachment.url).content, + }), + ), + } + : {}), + classifications: redactSensitiveContent(content).classifications, + }, + }, + userId, + ); + } catch (err) { + this.logger.error( + `Failed to persist user message for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + } + } + @SubscribeMessage('set:thinking') handleSetThinking( @ConnectedSocket() client: Socket, @@ -603,37 +585,35 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } - const session = this.agentService.getSession(data.conversationId, scope); - if (!session) { - client.emit('error', { - conversationId: data.conversationId, - error: 'No active session for this conversation.', - }); + const result = this.runtime.setLegacyThinking( + ownConversation(data.conversationId, scope), + data.level, + ); + if (!result.ok) { + if (result.code === 'thinking_level_invalid') { + client.emit('error', { + conversationId: data.conversationId, + error: `Invalid thinking level "${data.level}". Available: ${result.availableThinkingLevels.join(', ')}`, + }); + } else if (result.code === 'conversation_unavailable') { + client.emit('error', { + conversationId: data.conversationId, + error: 'No active session for this conversation.', + }); + } else { + client.emit('error', { + conversationId: data.conversationId, + error: 'Failed to set thinking level.', + }); + } return; } - const validLevels = session.piSession.getAvailableThinkingLevels(); - if (!validLevels.includes(data.level as never)) { - client.emit('error', { - conversationId: data.conversationId, - error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`, - }); - return; - } - - session.piSession.setThinkingLevel(data.level as never); this.logger.log( `Thinking level set to "${data.level}" for conversation ${data.conversationId}`, ); - client.emit('session:info', { - conversationId: data.conversationId, - provider: session.provider, - modelId: session.modelId, - thinkingLevel: session.piSession.thinkingLevel, - availableThinkingLevels: session.piSession.getAvailableThinkingLevels(), - ...(session.agentName ? { agentName: session.agentName } : {}), - }); + client.emit('session:info', { conversationId: data.conversationId, ...result.value }); } @SubscribeMessage('abort') @@ -650,28 +630,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } - const session = this.agentService.getSession(conversationId, scope); - if (!session) { + const result = await this.runtime.abortLegacyTurn(ownConversation(conversationId, scope)); + if (!result.ok) { client.emit('error', { conversationId, - error: 'No active session to abort.', + error: + result.code === 'conversation_unavailable' + ? 'No active session to abort.' + : 'Failed to abort the agent operation.', }); return; } - - try { - await session.piSession.abort(); - this.logger.log(`Agent session ${conversationId} aborted successfully`); - } catch (err) { - this.logger.error( - `Failed to abort session ${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - client.emit('error', { - conversationId, - error: 'Failed to abort the agent operation.', - }); - } + this.logger.log(`Agent session ${conversationId} aborted successfully`); } @SubscribeMessage('command:execute') @@ -741,7 +711,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately - this.agentService.updateSessionModel(conversationId, modelName, scope); + this.runtime.applyLegacyModelOverride(ownConversation(conversationId, scope), modelName); // M5-005: Broadcast session:info to all clients subscribed to this conversation this.broadcastSessionInfo(conversationId, scope); @@ -767,17 +737,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { - const agentSession = this.agentService.getSession(conversationId, scope); - if (!agentSession) return; + const result = this.runtime.readLegacySessionPresentation( + ownConversation(conversationId, scope), + ); + if (!result.ok) return; - const piSession = agentSession.piSession; - const resolvedAgentName = extra?.agentName ?? agentSession.agentName; + const resolvedAgentName = extra?.agentName ?? result.value.agentName; const payload = { conversationId, - provider: agentSession.provider, - modelId: agentSession.modelId, - thinkingLevel: piSession.thinkingLevel, - availableThinkingLevels: piSession.getAvailableThinkingLevels(), + ...result.value, ...(resolvedAgentName ? { agentName: resolvedAgentName } : {}), ...(extra?.routingDecision ? { routingDecision: extra.routingDecision } : {}), }; @@ -1044,45 +1012,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } - /** - * M5-004: Bind the agent sessionId to the conversation record in the DB. - * Updates the sessionId column so future resumes can reuse the session. - */ - private async bindSessionToConversation( - conversationId: string, - userId: string, - sessionId: string, - ): Promise { - try { - await this.brain.conversations.update(conversationId, userId, { sessionId }); - } catch (err) { - this.logger.error( - `Failed to bind sessionId=${sessionId} to conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - } - } - - /** - * M5-004: Retrieve the sessionId bound to a conversation, if any. - * Returns undefined when the conversation does not exist or has no bound session. - */ - private async getConversationSessionId( - conversationId: string, - userId: string, - ): Promise { - try { - const conv = await this.brain.conversations.findById(conversationId, userId); - return conv?.sessionId ?? undefined; - } catch (err) { - this.logger.error( - `Failed to get sessionId for conversation=${conversationId}`, - err instanceof Error ? err.stack : String(err), - ); - return undefined; - } - } - /** * Load prior conversation messages from DB for context injection on session resume (M1-004). * Returns an empty array when no history exists, the conversation is not owned by the user, @@ -1251,7 +1180,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return `${this.clientConversationKey(client, conversationId)}:${eventName}`; } - private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { + /** + * Relay one normalized {@link LegacyRuntimeEvent} to the socket, preserving the exact legacy + * egress event names and shapes. The runtime owns session/token bookkeeping — the gateway never + * reads a pi session or records usage here; usage arrives verbatim on the `settled` event. + */ + private relayEvent(client: Socket, conversationId: string, event: LegacyRuntimeEvent): void { if (!client.connected) { this.logger.warn( `Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`, @@ -1261,7 +1195,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { - case 'agent_start': { + case 'started': { // Reset accumulation buffers for the new turn const cs = this.clientSessions.get(sessionKey); if (cs) { @@ -1277,30 +1211,73 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa break; } - case 'agent_end': { - // Gather usage stats from the Pi session - const activeClientSession = this.clientSessions.get(sessionKey); - const agentSession = activeClientSession - ? this.agentService.getSession(conversationId, activeClientSession.scope) - : undefined; - const piSession = agentSession?.piSession; - const stats = piSession?.getSessionStats(); - const contextUsage = piSession?.getContextUsage(); + case 'text_delta': { + // Keep raw stream material in memory only; persist and emit only redacted text. + const cs = this.clientSessions.get(sessionKey); + if (cs) { + cs.assistantText += event.text; + } + this.appendAndFlushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + event.text, + ); + break; + } - const usagePayload = stats - ? { - provider: agentSession?.provider ?? 'unknown', - modelId: agentSession?.modelId ?? 'unknown', - thinkingLevel: piSession?.thinkingLevel ?? 'off', - tokens: stats.tokens, - cost: stats.cost, - context: { - percent: contextUsage?.percent ?? null, - window: contextUsage?.contextWindow ?? 0, - }, - } - : undefined; + case 'thinking_delta': { + this.appendAndFlushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + event.text, + ); + break; + } + case 'tool_started': { + // Track pending tool call for later recording + const cs = this.clientSessions.get(sessionKey); + if (cs) { + cs.pendingToolCalls.set(event.toolCallId, { + toolName: event.toolName, + args: undefined, + }); + } + client.emit('agent:tool:start', { + conversationId, + toolCallId: event.toolCallId, + toolName: event.toolName, + }); + break; + } + + case 'tool_finished': { + // Finalise tool call record + const cs = this.clientSessions.get(sessionKey); + if (cs) { + const pending = cs.pendingToolCalls.get(event.toolCallId); + cs.toolCalls.push({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: pending?.args ?? null, + isError: event.isError, + }); + cs.pendingToolCalls.delete(event.toolCallId); + } + client.emit('agent:tool:end', { + conversationId, + toolCallId: event.toolCallId, + toolName: event.toolName, + isError: event.isError, + }); + break; + } + + case 'settled': { this.flushRedactedEgress( client, conversationId, @@ -1315,21 +1292,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.thinkingEgressBuffers, true, ); - client.emit('agent:end', { - conversationId, - usage: usagePayload, - }); - - // M5-007: Accumulate token usage in session metrics - if (stats?.tokens) { - this.agentService.recordTokenUsage(conversationId, { - 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, - }); - } + client.emit('agent:end', { conversationId, usage: event.usage }); // Persist the assistant message with metadata const cs = this.clientSessions.get(sessionKey); @@ -1337,21 +1300,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { timestamp: new Date().toISOString(), - model: agentSession?.modelId ?? 'unknown', - provider: agentSession?.provider ?? 'unknown', + model: event.usage?.modelId ?? 'unknown', + provider: event.usage?.provider ?? 'unknown', toolCalls: cs.toolCalls, + ...(event.usage?.tokens ? { tokenUsage: event.usage.tokens } : {}), }; - if (stats?.tokens) { - metadata['tokenUsage'] = { - input: stats.tokens.input, - output: stats.tokens.output, - cacheRead: stats.tokens.cacheRead, - cacheWrite: stats.tokens.cacheWrite, - total: stats.tokens.total, - }; - } - this.brain.conversations .addMessage( { @@ -1379,72 +1333,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } break; } - - case 'message_update': { - const assistantEvent = event.assistantMessageEvent; - if (assistantEvent.type === 'text_delta') { - // Keep raw stream material in memory only; persist and emit only redacted text. - const cs = this.clientSessions.get(sessionKey); - if (cs) { - cs.assistantText += assistantEvent.delta; - } - this.appendAndFlushRedactedEgress( - client, - conversationId, - 'agent:text', - this.textEgressBuffers, - assistantEvent.delta, - ); - } else if (assistantEvent.type === 'thinking_delta') { - this.appendAndFlushRedactedEgress( - client, - conversationId, - 'agent:thinking', - this.thinkingEgressBuffers, - assistantEvent.delta, - ); - } - break; - } - - case 'tool_execution_start': { - // Track pending tool call for later recording - const cs = this.clientSessions.get(sessionKey); - if (cs) { - cs.pendingToolCalls.set(event.toolCallId, { - toolName: event.toolName, - args: event.args, - }); - } - client.emit('agent:tool:start', { - conversationId, - toolCallId: event.toolCallId, - toolName: event.toolName, - }); - break; - } - - case 'tool_execution_end': { - // Finalise tool call record - const cs = this.clientSessions.get(sessionKey); - if (cs) { - const pending = cs.pendingToolCalls.get(event.toolCallId); - cs.toolCalls.push({ - toolCallId: event.toolCallId, - toolName: event.toolName, - args: pending?.args ?? null, - isError: event.isError, - }); - cs.pendingToolCalls.delete(event.toolCallId); - } - client.emit('agent:tool:end', { - conversationId, - toolCallId: event.toolCallId, - toolName: event.toolName, - isError: event.isError, - }); - break; - } } } } diff --git a/apps/gateway/src/chat/chat.module.ts b/apps/gateway/src/chat/chat.module.ts index 026659f7..dacb35ad 100644 --- a/apps/gateway/src/chat/chat.module.ts +++ b/apps/gateway/src/chat/chat.module.ts @@ -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 {} diff --git a/apps/gateway/src/chat/embedded-chat.runtime.ts b/apps/gateway/src/chat/embedded-chat.runtime.ts new file mode 100644 index 00000000..bbcfe9b0 --- /dev/null +++ b/apps/gateway/src/chat/embedded-chat.runtime.ts @@ -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> + > { + 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((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> { + 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 { + 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> { + 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 { + 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 { + 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> { + 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, { 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> => { + 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 => { + 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 }; +} diff --git a/apps/gateway/src/chat/harness-chat.runtime.spec.ts b/apps/gateway/src/chat/harness-chat.runtime.spec.ts new file mode 100644 index 00000000..badc8c8d --- /dev/null +++ b/apps/gateway/src/chat/harness-chat.runtime.spec.ts @@ -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 { + 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 }); + }); +}); diff --git a/apps/gateway/src/chat/harness-chat.runtime.ts b/apps/gateway/src/chat/harness-chat.runtime.ts new file mode 100644 index 00000000..566d9820 --- /dev/null +++ b/apps/gateway/src/chat/harness-chat.runtime.ts @@ -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 { + return this.conversations.attach(input); + } + + detach(input: DetachConversation): Promise { + return this.conversations.detach(input); + } + + send(input: SendHarnessTurn & { idempotencyKey: string }): Promise { + return this.conversations.send(input); + } + + subscribeFrom( + conversationId: string, + afterSequence: number, + ): AsyncIterable { + return this.conversations.subscribeFrom(conversationId, afterSequence); + } +} diff --git a/apps/gateway/src/conversations/conversations-harness-fence.spec.ts b/apps/gateway/src/conversations/conversations-harness-fence.spec.ts new file mode 100644 index 00000000..90cc58a2 --- /dev/null +++ b/apps/gateway/src/conversations/conversations-harness-fence.spec.ts @@ -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); + }); +}); diff --git a/apps/gateway/src/conversations/conversations.controller.ts b/apps/gateway/src/conversations/conversations.controller.ts index 69dc8697..393a7ef7 100644 --- a/apps/gateway/src/conversations/conversations.controller.ts +++ b/apps/gateway/src/conversations/conversations.controller.ts @@ -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, diff --git a/apps/gateway/src/harness/harness.module.ts b/apps/gateway/src/harness/harness.module.ts index 4ecfd595..b4453f1f 100644 --- a/apps/gateway/src/harness/harness.module.ts +++ b/apps/gateway/src/harness/harness.module.ts @@ -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 {} diff --git a/apps/gateway/src/harness/harness.tokens.ts b/apps/gateway/src/harness/harness.tokens.ts index bbb4dd65..56d12c89 100644 --- a/apps/gateway/src/harness/harness.tokens.ts +++ b/apps/gateway/src/harness/harness.tokens.ts @@ -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; +} diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 3f12aa2a..b11eead6 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -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(); @@ -150,6 +155,57 @@ function createPayload(overrides: Partial = {}): 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 }, +): 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', () => { diff --git a/apps/web/src/lib/chat-contract.ts b/apps/web/src/lib/chat-contract.ts index f8da2fc3..57e700a1 100644 --- a/apps/web/src/lib/chat-contract.ts +++ b/apps/web/src/lib/chat-contract.ts @@ -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, diff --git a/apps/web/src/spa/chat/composer.tsx b/apps/web/src/spa/chat/composer.tsx index 2bd2d033..f43bfbf8 100644 --- a/apps/web/src/spa/chat/composer.tsx +++ b/apps/web/src/spa/chat/composer.tsx @@ -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): void { diff --git a/apps/web/src/spa/chat/use-chat-connection.spec.tsx b/apps/web/src/spa/chat/use-chat-connection.spec.tsx index 545516e2..3892a307 100644 --- a/apps/web/src/spa/chat/use-chat-connection.spec.tsx +++ b/apps/web/src/spa/chat/use-chat-connection.spec.tsx @@ -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; @@ -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[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 { + 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 { + 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' }); diff --git a/apps/web/src/spa/chat/use-chat-connection.ts b/apps/web/src/spa/chat/use-chat-connection.ts index b2a81dd2..8ce304a7 100644 --- a/apps/web/src/spa/chat/use-chat-connection.ts +++ b/apps/web/src/spa/chat/use-chat-connection.ts @@ -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; + 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: () => { diff --git a/apps/web/src/spa/chat/use-harness-selection.spec.tsx b/apps/web/src/spa/chat/use-harness-selection.spec.tsx index 38f953da..0c1b8f2d 100644 --- a/apps/web/src/spa/chat/use-harness-selection.spec.tsx +++ b/apps/web/src/spa/chat/use-harness-selection.spec.tsx @@ -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 () => { diff --git a/apps/web/src/spa/chat/use-harness-selection.ts b/apps/web/src/spa/chat/use-harness-selection.ts index 28d3761e..f17dcc4e 100644 --- a/apps/web/src/spa/chat/use-harness-selection.ts +++ b/apps/web/src/spa/chat/use-harness-selection.ts @@ -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, }; } diff --git a/apps/web/src/spa/pages/chat.spec.tsx b/apps/web/src/spa/pages/chat.spec.tsx index 79bfab8b..0641865c 100644 --- a/apps/web/src/spa/pages/chat.spec.tsx +++ b/apps/web/src/spa/pages/chat.spec.tsx @@ -108,6 +108,33 @@ async function flushAsync(times = 5): Promise { } } +/** 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; 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)); diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 075431af..6b0d447a 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -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; diff --git a/packages/types/src/chat/index.ts b/packages/types/src/chat/index.ts index feaa002b..d5cbaae6 100644 --- a/packages/types/src/chat/index.ts +++ b/packages/types/src/chat/index.ts @@ -14,6 +14,8 @@ export type { AbortPayload, ErrorPayload, ChatMessagePayload, + HarnessTurnSendPayload, + HarnessTurnAckPayload, ServerToClientEvents, ClientToServerEvents, } from './events.js';