From 216cd72226cd9ee17eea461cfe7cd0e010a22f02 Mon Sep 17 00:00:00 2001 From: shaggy Date: Wed, 12 Aug 2026 20:11:12 +0000 Subject: [PATCH] refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172) Co-authored-by: shaggy --- .../conversation-persistence.test.ts | 8 +- .../tess-cross-surface.integration.test.ts | 21 +- .../src/__tests__/resource-ownership.test.ts | 2 +- .../agent/__tests__/session-ownership.test.ts | 852 +++++++++++-- .../src/chat/__tests__/chat-security.test.ts | 1127 ++++++++++++++++- .../src/chat/chat-runtime-router.spec.ts | 920 ++++++++++++++ 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 | 132 +- .../src/chat/chat.gateway-redaction.spec.ts | Bin 7269 -> 7430 bytes apps/gateway/src/chat/chat.gateway.ts | 1029 ++++++++------- apps/gateway/src/chat/chat.module.ts | 53 +- .../gateway/src/chat/embedded-chat.runtime.ts | 532 ++++++++ .../src/chat/harness-chat.runtime.spec.ts | 170 +++ apps/gateway/src/chat/harness-chat.runtime.ts | 47 + .../conversations-harness-fence.spec.ts | 116 ++ .../conversations/conversations.controller.ts | 42 +- .../src/conversations/conversations.module.ts | 7 + apps/gateway/src/harness/harness.module.ts | 13 +- apps/gateway/src/harness/harness.tokens.ts | 34 + .../plugin/discord-ingress.security.spec.ts | 700 +++++++++- apps/web/src/lib/chat-contract.ts | 10 + apps/web/src/spa/chat/composer.tsx | 15 +- .../spa/chat/test-support/fake-chat-socket.ts | 14 +- .../src/spa/chat/use-chat-connection.spec.tsx | 868 ++++++++++++- apps/web/src/spa/chat/use-chat-connection.ts | 263 +++- .../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 | 213 ++++ packages/types/src/chat/events.ts | 65 + packages/types/src/chat/index.ts | 4 + 33 files changed, 7209 insertions(+), 672 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__/conversation-persistence.test.ts b/apps/gateway/src/__tests__/conversation-persistence.test.ts index 781d2957..0907b155 100644 --- a/apps/gateway/src/__tests__/conversation-persistence.test.ts +++ b/apps/gateway/src/__tests__/conversation-persistence.test.ts @@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => { }, ]; brain = createMockBrain({ searchResults }); - controller = new ConversationsController(brain as never); + controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); }); it('returns matching messages for a valid search query', async () => { @@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => { describe('ConversationsController — message CRUD', () => { it('listMessages returns 404 when conversation is not owned by user', async () => { const brain = createMockBrain({ conversation: undefined }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf( NotFoundException, @@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => { it('listMessages returns the messages for an owned conversation', async () => { const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')]; const brain = createMockBrain({ conversation: makeConversation(), messages: msgs }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); const result = await controller.listMessages(CONV_ID, { id: USER_ID }); @@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => { it('addMessage returns the persisted message', async () => { const brain = createMockBrain({ conversation: makeConversation() }); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); const result = await controller.addMessage( CONV_ID, 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/__tests__/resource-ownership.test.ts b/apps/gateway/src/__tests__/resource-ownership.test.ts index 821a7a62..cd2bbacf 100644 --- a/apps/gateway/src/__tests__/resource-ownership.test.ts +++ b/apps/gateway/src/__tests__/resource-ownership.test.ts @@ -60,7 +60,7 @@ describe('Resource ownership checks', () => { // The repo enforces ownership via the WHERE clause; it returns undefined when the // conversation does not belong to the requesting user. brain.conversations.findById.mockResolvedValue(undefined); - const controller = new ConversationsController(brain as never); + const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' }); await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf( NotFoundException, diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts index 4fc0a284..ce2e6ddb 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 { Test, type TestingModule } from '@nestjs/testing'; import { describe, expect, it, vi } from 'vitest'; vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} })); @@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({ })); import { SessionsController } from '../sessions.controller.js'; +import { AgentService } from '../agent.service.js'; import { ChatController } from '../../chat/chat.controller.js'; import { ChatGateway } from '../../chat/chat.gateway.js'; import type { AgentSession } from '../agent.service.js'; import type { SessionInfoDto } from '../session.dto.js'; +import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; +import { AuthGuard } from '../../auth/auth.guard.js'; +import { AUTH } from '../../auth/auth.tokens.js'; +import { BRAIN } from '../../brain/brain.tokens.js'; +import { CommandRegistryService } from '../../commands/command-registry.service.js'; +import { CommandExecutorService } from '../../commands/command-executor.service.js'; +import { RoutingEngineService } from '../routing/routing-engine.service.js'; +import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js'; +import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js'; +import { ownConversation } from '../../chat/chat-runtime.js'; +import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js'; +import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js'; +import { HarnessRegistry } from '../../harness/harness.registry.js'; +import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js'; const USER_A = { id: 'user-a', tenantId: 'tenant-a' }; const USER_B = { id: 'user-b', tenantId: 'tenant-b' }; @@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession { }; } +/** + * A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing + * USER_A's conversation id) is never granted the session. Because every method exists and no method + * throws for a wrong shape, production runs to its real ownership decision — the RED never comes from + * a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch. + */ function makeScopedAgentService() { const foreign = makeAgentSession(USER_A); return { @@ -87,7 +110,7 @@ function makeScopedAgentService() { getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => scope?.userId === USER_B.id ? undefined : foreign, ), - createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')), + createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')), onEvent: vi.fn(() => vi.fn()), addChannel: vi.fn(), removeChannel: vi.fn(), @@ -96,6 +119,201 @@ function makeScopedAgentService() { }; } +type ScopedAgentService = ReturnType; + +/** + * 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: { + // The sender OWNS this durable conversation, so the browser-send admission gate lets the turn + // reach the router seam. Foreignness is asserted downstream at the in-memory agent session + // (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection + // property has its own dedicated coverage. + findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }), + }, + }; + return Test.createTestingModule({ + providers: [ + ChatGateway, + { provide: AgentService, useValue: directAgentService }, + { provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } }, + { provide: BRAIN, useValue: brain }, + { provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } }, + { provide: CommandExecutorService, useValue: { execute: vi.fn() } }, + { + provide: RoutingEngineService, + useValue: { + resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), + }, + }, + { + provide: ChatRuntimeRouter, + useFactory: () => + makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls), + }, + ], + }).compile(); +} + describe('TESS-M1-SEC-002 AgentService ownership boundary', () => { it('requires explicit owner+tenant scope on protected session operations', () => { const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8'); @@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => { }); }); -describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => { - it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => { - const agentService = makeScopedAgentService(); - const controller = new ChatController(agentService as never); +describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => { + // TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot + // swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived + // scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path. + it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency + const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation + const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam + const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls); + try { + const controller = moduleRef.get(ChatController, { strict: false }); - await expect( - controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), - ).rejects.toMatchObject({ status: 404 }); + // Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN. + await expect( + controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), + ).rejects.toBeDefined(); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, - }); - expect(agentService.prompt).not.toHaveBeenCalled(); + // Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail. + + // RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router. + // Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the + // controller @Inject(AgentService) and never calls the router). GREEN once it drives the op. + expect + .soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router') + .toContain('completeLegacyRestTurn'); + // RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on + // the router. An AgentService-shaped router shim records one of these → RED, defeating the shim + // on invocation evidence (not source text). A dead named method added alongside the shim does not + // help: it is never invoked, so it never enters routerCalls while a forbidden op still does. + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + // RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today; + // restoring the direct injection keeps it failing). + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + // RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the + // router (fails today; the router path is never taken). + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + + // Zero foreign mutation on either path (holds today and at GREEN). + expect.soft(directAgentService.prompt).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled(); + + // Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the + // controller no longer declares the direct embedded AgentService dependency. A negative source + // check cannot be satisfied by dead text — it only fails when the injection is present. + const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); + expect.soft(controllerSource).not.toContain('@Inject(AgentService)'); + } finally { + await moduleRef.close(); + } }); }); -describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => { - function makeGateway(agentService = makeScopedAgentService()) { - const brain = { - conversations: { - findById: vi.fn().mockResolvedValue(undefined), - create: vi.fn().mockResolvedValue(undefined), - update: vi.fn().mockResolvedValue(undefined), - findMessages: vi.fn().mockResolvedValue([]), - addMessage: vi.fn().mockResolvedValue(undefined), - }, - }; - const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) }; - const commandExecutor = { execute: vi.fn() }; - const routingEngine = { - resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), - }; - const gateway = new ChatGateway( - agentService as never, - {} as never, - brain as never, - commandRegistry as never, - commandExecutor as never, - routingEngine as never, - ); - return { gateway, agentService }; - } - +describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => { function makeSocket() { return { id: 'socket-b', @@ -206,57 +440,519 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => }; } - it('does not attach or send to another owner/tenant session by guessed conversationId', async () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + // TESS test B — WebSocket send/attach. + it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); - await gateway.handleMessage(socket as never, { - conversationId: CONVERSATION_ID, - content: 'attach to foreign session', - }); + await Promise.resolve( + gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'attach to foreign session', + }), + ).catch(() => undefined); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, - }); - expect(agentService.onEvent).not.toHaveBeenCalled(); - expect(agentService.addChannel).not.toHaveBeenCalled(); + // RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router. + expect + .soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router') + .toContain('prepareLegacySocketTurn'); + // RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + // RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it). + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + // RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached). + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + // Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN). + expect.soft(directAgentService.onEvent).not.toHaveBeenCalled(); + expect.soft(directAgentService.addChannel).not.toHaveBeenCalled(); + expect.soft(directAgentService.prompt).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled(); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + + // Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency. + const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8'); + expect.soft(gatewaySource).not.toContain('@Inject(AgentService)'); + } finally { + await moduleRef.close(); + } + }); + + // TESS test C — WebSocket set:thinking. + it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); + + await Promise.resolve( + gateway.handleSetThinking(socket as never, { + conversationId: CONVERSATION_ID, + level: 'high', + }), + ).catch(() => undefined); + + // RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router. + expect + .soft(routerCalls, 'gateway must invoke setLegacyThinking on the router') + .toContain('setLegacyThinking'); + // RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + } finally { + await moduleRef.close(); + } + }); + + // TESS test D — WebSocket abort. + it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => { + const directAgentService = makeScopedAgentService(); + const embeddedAgentService = makeScopedAgentService(); + const routerCalls: string[] = []; + const moduleRef = await buildGatewayModule( + directAgentService, + embeddedAgentService, + routerCalls, + ); + try { + const gateway = moduleRef.get(ChatGateway, { strict: false }); + const socket = makeSocket(); + + await Promise.resolve( + gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }), + ).catch(() => undefined); + + // RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router. + expect + .soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router') + .toContain('abortLegacyTurn'); + // RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim). + for (const op of FORBIDDEN_AGENT_OPS) { + expect + .soft(routerCalls, `router seam must not invoke AgentService.${op}`) + .not.toContain(op); + } + expect.soft(directAgentService.getSession).not.toHaveBeenCalled(); + expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect + .soft(socket.emit) + .toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + } finally { + await moduleRef.close(); + } + }); + + // TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal. + it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => { + // pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and + // must be refused with a fixed typed code, touching neither the embedded AgentService nor the + // harness conversation service. + const agentService = makeScopedAgentService(); + const embedded = new EmbeddedChatRuntime(agentService as never); + const harnessConversation = { + attach: vi.fn(), + detach: vi.fn(), + send: vi.fn(), + subscribeFrom: vi.fn(), + }; + const harness = new HarnessChatRuntime(harnessConversation as never); + const router = new ChatRuntimeRouter( + registryWith(['pi']), + boundConversationService, + embedded, + harness, + 'pi-rpc', + ); + router.onModuleInit(); + + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const gateway = new ChatGateway( + router as never, + {} as never, + brain as never, + { getManifest: vi.fn().mockReturnValue([]) } as never, + { execute: vi.fn() } as never, + { resolve: vi.fn() } as never, + ); + const socket = { + id: 'socket-b', + connected: true, + data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } }, + emit: vi.fn(), + disconnect: vi.fn(), + }; + + await Promise.resolve( + gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'route me', + }), + ).catch(() => undefined); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'runtime_unsupported' }), + ); + expect(agentService.getSession).not.toHaveBeenCalled(); expect(agentService.prompt).not.toHaveBeenCalled(); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), + expect(harnessConversation.attach).not.toHaveBeenCalled(); + expect(harnessConversation.send).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5). +// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService +// fake (every touched method exists, so a RED can only come from behavior, never a +// `getSession is not a function` TypeError). Ownership context is minted through the +// real `ownConversation` factory — the only sanctioned way to reach a port op. +// --------------------------------------------------------------------------- + +const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId }; +const CONVERSATION_UNAVAILABLE_RESULT = { + ok: false, + code: 'conversation_unavailable', + retryable: false, +} as const; + +/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */ +function makeStream(): LegacyRuntimeStream { + return { channelId: 'websocket:test-1', onEvent: vi.fn() }; +} + +/** + * getSession → undefined (session missing), createSession → rejects with `err`. Exercises the + * `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves + * the turn short-circuited before any dispatch. + */ +function makeCollapsingAgentService(err: Error) { + return { + getSession: vi.fn(() => undefined), + createSession: vi.fn().mockRejectedValue(err), + onEvent: vi.fn(() => vi.fn()), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + recordTokenUsage: vi.fn(), + }; +} + +/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */ +function makeLeaseAgentService() { + const session = makeAgentSession(USER_A); + const unsubscribe = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => unsubscribe), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + recordTokenUsage: vi.fn(), + }; + return { svc, unsubscribe, session }; +} + +/** + * getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach` + * spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single + * idempotent teardown must clear the 120s timeout and detach the listener exactly once. + */ +function makeRejectingPromptAgentService() { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')), + recordTokenUsage: vi.fn(), + }; + return { svc, detach }; +} + +describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => { + const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner')); + const runtime = new EmbeddedChatRuntime(svc as never); + + const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' }); + + expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT); + expect(svc.prompt).not.toHaveBeenCalled(); + }); + + it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => { + const svc = makeCollapsingAgentService(new NotFoundException('no such conversation')); + const runtime = new EmbeddedChatRuntime(svc as never); + + const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' }); + + expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT); + expect(svc.prompt).not.toHaveBeenCalled(); + }); + + it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => { + const foreign = new EmbeddedChatRuntime( + makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never, ); + const missing = new EmbeddedChatRuntime( + makeCollapsingAgentService(new NotFoundException('no such conversation')) as never, + ); + + const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' }); + const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' }); + + expect(foreignResult).toEqual(missingResult); + expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT); }); +}); + +describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => { + const { svc } = makeLeaseAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); - it('does not mutate thinking level on another owner/tenant session', () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream()); + expect(prepared.ok).toBe(true); + if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed'); + const lease = prepared.value; - gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' }); + const first = await lease.dispatch(); + expect(first).toEqual({ ok: true, value: undefined }); + expect(svc.prompt).toHaveBeenCalledTimes(1); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, + const second = await lease.dispatch(); + expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false }); + // Zero additional effect — the second dispatch must not prompt again. + expect(svc.prompt).toHaveBeenCalledTimes(1); + }); + + it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => { + const { svc, unsubscribe, session } = makeLeaseAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); + + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream()); + expect(prepared.ok).toBe(true); + if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed'); + const lease = prepared.value; + + await lease.dispose(); + await lease.dispose(); + + // Listener + channel torn down exactly once across two dispose calls. + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(svc.removeChannel).toHaveBeenCalledTimes(1); + // Disposal never terminates the underlying session or process. + expect(session.piSession.abort).not.toHaveBeenCalled(); + expect(session.piSession.dispose).not.toHaveBeenCalled(); + }); + + it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => { + const { svc, unsubscribe } = makeLeaseAgentService(); + svc.addChannel = vi.fn(() => { + throw new Error('channel attach failed'); }); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), - ); + const runtime = new EmbeddedChatRuntime(svc as never); + + // Must NOT throw out of the port — a partial setup collapses to a total safe failure. + const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream()); + expect(prepared.ok).toBe(false); + // Exactly what was acquired (the event listener) is rolled back. + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); + +describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => { + const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE); + + it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => { + const { svc, detach } = makeRejectingPromptAgentService(); + const runtime = new EmbeddedChatRuntime(svc as never); + + // A rejected `done` promise firing after completeLegacyRestTurn has already returned would + // surface as an unhandledRejection — the leak this test fences. Capture any that escape. + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const result = await runtime.completeLegacyRestTurn(ctx, { + content: 'trigger a backend failure', + }); + + // The rejection collapses to a total safe failure (not a timeout) — never throws out of the port. + expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false }); + // The single idempotent dispose ran in the catch: listener detached exactly once. + expect(detach).toHaveBeenCalledTimes(1); + + // dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second + // detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise. + vi.advanceTimersByTime(600_000); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + // Let any scheduled rejection surface on a real macrotask, then confirm none did. + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); }); + + it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + // The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential + // `await prompt()` the timer could never even be observed, so the turn hung forever. + prompt: vi.fn(() => new Promise(() => undefined)), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); - it('does not terminate another owner/tenant session over WebSocket abort', async () => { - const { gateway, agentService } = makeGateway(); - const socket = makeSocket(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'a prompt that never returns', + }); + // No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all + // installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; - await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }); + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + // The single idempotent dispose ran on the timeout path: listener detached exactly once. + expect(detach).toHaveBeenCalledTimes(1); + // Advancing far past the deadline fires nothing more: dispose cleared the timer. + vi.advanceTimersByTime(600_000); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); + }); - expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { - userId: USER_B.id, - tenantId: USER_B.tenantId, + it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + let rejectPrompt: (reason: unknown) => void = () => undefined; + const prompting = new Promise((_resolve, reject) => { + rejectPrompt = reject; }); - expect(socket.emit).toHaveBeenCalledWith( - 'error', - expect.objectContaining({ conversationId: CONVERSATION_ID }), - ); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn(() => prompting), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'prompt settles after the deadline', + }); + // The timeout wins the race while prompt is still pending. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; + + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + expect(detach).toHaveBeenCalledTimes(1); + + // The prompt now rejects LATE — after the turn already returned its timeout result. Because + // Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late + // rejection is already observed and must not escape as an unhandledRejection. + rejectPrompt(new Error('late backend failure')); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); }); }); diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 45bd1f71..61af835d 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,791 @@ 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, and — the + // Task 5 send-capability rule — no send-protocol advertisement to an unauthenticated socket. + 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()); + expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', 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(); + }, + ); + + // A minimal authenticated-browser connection harness for the send-capability advertisement. + const connectAuthedBrowser = async ( + mode: 'legacy' | 'pi-rpc', + clientId: string, + ): Promise<{ emit: ReturnType; disconnect: ReturnType }> => { + 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() }; + const auth = { + api: { + getSession: vi + .fn() + .mockResolvedValue({ user: { id: 'user-a' }, session: { id: 'session-a' } }), + }, + }; + const gateway = new ChatGateway( + readyRouter(mode, agentService, harnessConversations) as never, + auth as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + { getManifest: vi.fn().mockReturnValue({ commands: [] }) } as never, + {} as never, + {} as never, + ); + const client = { + id: clientId, + handshake: { auth: {}, headers: { cookie: 'session=abc' } }, + data: {} as Record, + emit: vi.fn(), + disconnect: vi.fn(), + }; + await gateway.handleConnection(client as never); + return client as never; + }; + + it('advertises legacy-message exactly once to an authenticated browser in legacy mode (Task 5 MAJOR-1)', async () => { + const client = await connectAuthedBrowser('legacy', 'browser-cap-legacy'); + + // Legacy mode: the connected Gateway handles the `message` event, so it advertises + // `legacy-message` — targeted, connection-bound, exactly once. + expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { + protocol: 'legacy-message', + connectionId: 'browser-cap-legacy', + }); + const capabilityCalls = client.emit.mock.calls.filter( + (call: unknown[]) => call[0] === 'chat:send-capability', + ); + expect(capabilityCalls).toHaveLength(1); + expect(client.disconnect).not.toHaveBeenCalled(); + }); + + it('advertises unavailable (never turn-send) to an authenticated browser in pi-rpc mode (Task 5 MAJOR-1)', async () => { + const client = await connectAuthedBrowser('pi-rpc', 'browser-cap-pirpc'); + + // pi-rpc mode: the legacy `message` handler fails closed and the authenticated `turn:send` + // handler lands in Task 15, so Task 5 advertises `unavailable` — never `turn-send`. + expect(client.emit).toHaveBeenCalledWith('chat:send-capability', { + protocol: 'unavailable', + connectionId: 'browser-cap-pirpc', + }); + const capabilityCalls = client.emit.mock.calls.filter( + (call: unknown[]) => call[0] === 'chat:send-capability', + ); + expect(capabilityCalls).toHaveLength(1); + expect(capabilityCalls[0]?.[1]).not.toMatchObject({ protocol: 'turn-send' }); + }); + + it.each(['legacy', 'pi-rpc'] as const)( + 'never advertises send-capability to a Discord service socket (%s mode, Task 5 MAJOR-1)', + async (mode) => { + process.env['CHAT_HARNESS_RUNTIME'] = mode; + process.env['DISCORD_SERVICE_TOKEN'] = 'super-secret-discord-token'; + 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 gateway = new ChatGateway( + readyRouter(mode, agentService, { append: vi.fn() }) as never, + { api: { getSession: vi.fn() } } as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: `discord-service-${mode}`, + handshake: { auth: { discordServiceToken: 'super-secret-discord-token' }, headers: {} }, + data: {} as Record, + emit: vi.fn(), + disconnect: vi.fn(), + }; + + await gateway.handleConnection(client as never); + + // The trusted Discord service socket is not a browser; it never receives a browser + // send-protocol advertisement. + expect(client.data.discordService).toBe(true); + expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything()); + delete process.env['DISCORD_SERVICE_TOKEN']; + }, + ); + + // Record-and-forward instrumentation at the REAL returned-lease boundary: wrap the lease's own + // dispatch/dispose so the test observes the Gateway's invocation counts through the real + // ChatRuntimeRouter -> EmbeddedChatRuntime path — no canned lease, synthesized method, or shim. + const instrumentLease = ( + router: ChatRuntimeRouter, + order: string[], + counters: { dispatch: number; dispose: number }, + ): void => { + const realPrepare = router.prepareLegacySocketTurn.bind(router) as ( + ...args: unknown[] + ) => Promise<{ ok: boolean; value?: { dispatch: () => unknown; dispose: () => unknown } }>; + vi.spyOn(router, 'prepareLegacySocketTurn').mockImplementation((async (...args: unknown[]) => { + const result = await realPrepare(...args); + if (result.ok && result.value) { + const lease = result.value; + const realDispatch = lease.dispatch.bind(lease); + const realDispose = lease.dispose.bind(lease); + lease.dispatch = (): unknown => { + counters.dispatch += 1; + order.push('dispatch'); + return realDispatch(); + }; + lease.dispose = (): unknown => { + counters.dispose += 1; + return realDispose(); + }; + } + return result; + }) as never); + }; + + it('orders a legacy browser turn persist -> ack -> lease.dispatch -> prompt, each exactly once (Task 5 G2)', async () => { + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockImplementation(async (): Promise => { + order.push('prompt'); + }), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-order-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockImplementation(async (): Promise<{ id: string }> => { + order.push('persist'); + return { id: 'message-order-1' }; + }), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-order-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn().mockImplementation((event: string): void => { + if (event === 'message:ack') order.push('ack'); + }), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-order-1', + content: 'ordered hello', + } as never, + ); + + // The Gateway persists the user turn, THEN acks, THEN invokes the one-shot lease dispatch which + // finally prompts. Observed at the real lease boundary: dispatch is invoked exactly once and the + // runtime prompts exactly once. + expect(order).toEqual(['persist', 'ack', 'dispatch', 'prompt']); + expect(counters.dispatch).toBe(1); + expect(agentService.prompt).toHaveBeenCalledTimes(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('drops a legacy browser turn on persistence failure: zero info/ack/dispatch/prompt, one clean disposal (Task 5 G2)', async () => { + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const unsub = vi.fn(); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue(unsub), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-fail-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockRejectedValue(new Error('persistence unavailable')), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-fail-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-fail-1', + content: 'will fail persistence', + } as never, + ); + + // A failed user-message persistence aborts the turn with no accept-then-lose: no session:info, + // no ack, the lease never dispatches or prompts, and the just-prepared lease is disposed exactly + // once (listener/channel cleanup) without throwing. The fixed safe error is surfaced. + expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(counters.dispatch).toBe(0); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispose).toBe(1); + expect(unsub).toHaveBeenCalledTimes(1); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'persist_failed' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('drops a legacy browser turn when persistence RESOLVES nullish (not only on rejection): zero info/ack/dispatch/prompt, one clean disposal (Task 5 finding 2)', async () => { + // Companion to the rejected-promise case above. A brain adapter that resolves `undefined`/`null` + // instead of throwing must be treated as a persistence FAILURE, never as a saved message — the + // pre-fix code accepted a nullish resolve and dispatched a turn whose user message was never + // durably stored. RED before finding-2: the turn acks + dispatches + prompts on a phantom persist. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const unsub = vi.fn(); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue(unsub), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'conversation-nullish-1' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + // Resolves nullish rather than throwing: the fix must still fail the turn closed. + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-nullish-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-nullish-1', + content: 'persist resolves undefined', + } as never, + ); + + expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(counters.dispatch).toBe(0); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispose).toBe(1); + expect(unsub).toHaveBeenCalledTimes(1); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'persist_failed' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it("fails a legacy browser send closed when the supplied conversationId is not the sender's: conversation_unavailable, zero persist/mint/dispatch/prompt (Task 5 finding 1)", async () => { + // A browser socket that supplies a conversationId it does not own must be refused at admission, + // BEFORE any runtime effect. Pre-fix, an unresolved/foreign id fell through to session mint + + // dispatch, letting a caller attach to (or resurrect) a conversation outside their scope. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(undefined), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const addMessage = vi.fn().mockResolvedValue({ id: 'must-not-persist' }); + const brain = { + conversations: { + // Scoped lookup: the sender does not own this id, so admission resolves undefined. + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-foreign-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + conversationId: 'conversation-foreign-1', + content: 'let me into a conversation I do not own', + } as never, + ); + + // Admission consults the scoped durable record with the SENDER's id, then fails closed: no + // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(brain.conversations.findById).toHaveBeenCalledWith('conversation-foreign-1', 'user-a'); + expect(addMessage).not.toHaveBeenCalled(); + expect(brain.conversations.create).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.onEvent).not.toHaveBeenCalled(); + expect(agentService.addChannel).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('admits a legacy browser send with NO conversationId by minting a scoped durable record first, then dispatches once (Task 5 finding 1)', async () => { + // The distinct server-minted-new path: a client that omits conversationId is a brand-new + // conversation. Admission must CREATE the durable record (scoped to the sender) before the turn + // persists/dispatches, and must not consult the ownership lookup (there is nothing to authorize + // yet). This guards the fix from over-reaching and breaking new-conversation creation. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + // The durable store honours the mint: it returns the exact record asked for (same id, same + // scoped owner). Admission requires that non-null, correctly-identified record before any effect. + const create = vi.fn((data: { id: string; userId: string }) => + Promise.resolve({ id: data.id, userId: data.userId }), + ); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockResolvedValue({ id: 'persisted-new' }), + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message with no conversationId', + } as never, + ); + + // The durable record is minted for the sender (create with the same server id the turn persists + // under), and the admitted new-conversation turn dispatches exactly once. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(counters.dispatch).toBe(1); + expect(agentService.prompt).toHaveBeenCalledTimes(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // Minting is not fire-and-forget: if create resolves nullish (the durable write silently + // produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch + // against a conversation that was never persisted. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-nullish-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint resolves nullish', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed: no persist, no session + // mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // A create that rejects (durable store error) is a persistence failure, not a reason to proceed: + // the exception is caught at the admission seam and collapses to the same fail-closed refusal. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockRejectedValue(new Error('durable store unavailable')); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-throw-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint throws', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed on the thrown error: no + // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + 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..b5fb9660 --- /dev/null +++ b/apps/gateway/src/chat/chat-runtime-router.spec.ts @@ -0,0 +1,920 @@ +import 'reflect-metadata'; +import { Global, Module } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types'; +import { AgentService } from '../agent/agent.service.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { HarnessModule } from '../harness/harness.module.js'; +import { ChatModule } from './chat.module.js'; +import { ChatGateway } from './chat.gateway.js'; +import { HarnessRegistry } from '../harness/harness.registry.js'; +import { + HARNESS_CONVERSATION_SERVICE, + HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + HARNESS_REGISTRY, + type HarnessConversationServiceBinding, +} from '../harness/harness.tokens.js'; +import { ChatRuntimeRouter } from './chat-runtime-router.js'; +import { + ChatRuntimeUnavailableError, + ownConversation, + type ChatRuntime, + type ChatRuntimeMode, + type LegacyEmbeddedChatPort, + type LegacyRuntimeStream, + type LegacySessionPresentation, + type LegacySocketTurnLease, + type OwnedConversationContext, +} from './chat-runtime.js'; +import { AppModule } from '../app.module.js'; +import { ProviderService } from '../agent/provider.service.js'; + +/** + * Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one + * runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and + * never downgrades `pi-rpc` to embedded execution. Red-first: the router is an + * unimplemented stub, so every behavioural assertion below fails until Step Three. + */ + +const embedded: ChatRuntime = { kind: 'embedded' }; +const harness: ChatRuntime = { kind: 'harness' }; + +/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */ +const boundConversationService = { + attach: () => Promise.reject(new Error('unused')), + detach: () => Promise.reject(new Error('unused')), + send: () => Promise.reject(new Error('unused')), + + subscribeFrom: async function* () { + throw new Error('unused'); + }, +} as unknown as HarnessConversationService; + +function registryWith(adapterIds: readonly string[]): HarnessRegistry { + const registry = new HarnessRegistry(); + for (const id of adapterIds) { + registry.register({ + id, + describe: () => Promise.reject(new Error('unused')), + catalog: () => Promise.reject(new Error('unused')), + create: () => Promise.reject(new Error('unused')), + resume: () => Promise.reject(new Error('unused')), + } as HarnessAdapter); + } + return registry; +} + +function buildRouter( + mode: ChatRuntimeMode, + opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding }, +): ChatRuntimeRouter { + return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode); +} + +/** + * Tear down a module that was deliberately driven to a fail-closed init. + * `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing + * (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed + * startup error, this time into teardown. Each caller here has already captured and asserted that + * exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise — + * swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly. + */ +async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise { + await moduleRef.close().catch((err: unknown) => { + if (err instanceof ChatRuntimeUnavailableError) return; + throw err; + }); +} + +describe('ChatRuntimeRouter', () => { + it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => { + const router = buildRouter('pi-rpc', { + adapters: ['pi'], + service: boundConversationService, + }); + + expect(() => router.onModuleInit()).not.toThrow(); + expect(router.active).toBe(harness); + expect(router.active.kind).toBe('harness'); + }); + + it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => { + // Empty registry + unavailable service: legacy must ignore both and still start. + const router = buildRouter('legacy', { + adapters: [], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + expect(() => router.onModuleInit()).not.toThrow(); + expect(router.active).toBe(embedded); + expect(router.active.kind).toBe('embedded'); + }); + + it('fails closed at init when pi-rpc mode has no registered pi adapter', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + + expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError); + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw when the pi adapter is absent'); + } catch (err) { + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } + }); + + it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => { + const router = buildRouter('pi-rpc', { + adapters: ['pi'], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw when the conversation service is unbound'); + } catch (err) { + expect(err).toBeInstanceOf(ChatRuntimeUnavailableError); + expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable'); + expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported'); + } + }); + + it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE, + }); + + expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError); + // A failed pi-rpc init must not silently expose the embedded runtime. + expect(() => router.active).toThrow(); + let leaked: ChatRuntime | undefined; + try { + leaked = router.active; + } catch { + leaked = undefined; + } + expect(leaked).not.toBe(embedded); + }); + + it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => { + const router = buildRouter('pi-rpc', { + adapters: [], + service: boundConversationService, + }); + + try { + router.onModuleInit(); + expect.unreachable('onModuleInit must throw'); + } catch (err) { + const message = (err as ChatRuntimeUnavailableError).message; + expect(message).toBe( + 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.', + ); + expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/); + } + }); +}); + +/** + * Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input). + * + * The unit suite above constructs the router but never invokes a legacy port operation, so the + * six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that + * deletes one of them SURVIVES for lack of a test that drives that operation. This group closes + * that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a + * valid branded {@link OwnedConversationContext} and valid input, against a recording embedded + * stub whose method returns a distinguishable `ok:true` success and increments a per-op counter. + * + * For each operation: + * - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }` + * result AND that the embedded stub was touched zero times (no effects); + * - the paired legacy test proves that same stub method IS reached and returns its distinguishable + * success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful, + * not vacuously true because the stub could never be called. + * + * Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED + * (the router returns the embedded `ok:true` value and records the call), with every outer guard + * and the other five inner guards intact. `next` is untouched; nothing here changes production. + */ +describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => { + const RUNTIME_UNSUPPORTED = { + ok: false, + code: 'runtime_unsupported', + retryable: false, + } as const; + + const PRESENTATION: LegacySessionPresentation = { + provider: 'embedded-provider', + modelId: 'embedded-model', + thinkingLevel: 'low', + availableThinkingLevels: ['low', 'high'], + }; + + const stream: LegacyRuntimeStream = { + channelId: 'websocket:test-socket', + onEvent: () => {}, + }; + + const ctx = (): OwnedConversationContext => + ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' }); + + /** + * Per-operation invocation counters with declared keys (not an index signature) so each + * `calls.` is definitely `number` under `noUncheckedIndexedAccess`. + */ + type LegacyPortCallCounts = { + completeLegacyRestTurn: number; + prepareLegacySocketTurn: number; + setLegacyThinking: number; + abortLegacyTurn: number; + applyLegacyModelOverride: number; + readLegacySessionPresentation: number; + dispatchVerifiedDiscordIngress: number; + }; + + /** + * An embedded port that records every invocation and returns a distinguishable `ok:true` + * value per operation. If a router op reaches it (its guard removed), both the recorded call + * count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result. + */ + function recordingEmbeddedPort(): { + port: ChatRuntime & LegacyEmbeddedChatPort; + calls: LegacyPortCallCounts; + } { + const calls: LegacyPortCallCounts = { + completeLegacyRestTurn: 0, + prepareLegacySocketTurn: 0, + setLegacyThinking: 0, + abortLegacyTurn: 0, + applyLegacyModelOverride: 0, + readLegacySessionPresentation: 0, + dispatchVerifiedDiscordIngress: 0, + }; + const lease: LegacySocketTurnLease = { + presentation: PRESENTATION, + dispatch: () => Promise.resolve({ ok: true, value: undefined }), + dispose: () => Promise.resolve(), + }; + const port: ChatRuntime & LegacyEmbeddedChatPort = { + kind: 'embedded', + completeLegacyRestTurn: () => { + calls.completeLegacyRestTurn += 1; + return Promise.resolve({ + ok: true, + value: { text: 'EMBEDDED-REST', presentation: PRESENTATION }, + }); + }, + prepareLegacySocketTurn: () => { + calls.prepareLegacySocketTurn += 1; + return Promise.resolve({ ok: true, value: lease }); + }, + setLegacyThinking: () => { + calls.setLegacyThinking += 1; + return { ok: true, value: PRESENTATION }; + }, + abortLegacyTurn: () => { + calls.abortLegacyTurn += 1; + return Promise.resolve({ ok: true, value: undefined }); + }, + applyLegacyModelOverride: () => { + calls.applyLegacyModelOverride += 1; + return { ok: true, value: PRESENTATION }; + }, + readLegacySessionPresentation: () => { + calls.readLegacySessionPresentation += 1; + return { ok: true, value: PRESENTATION }; + }, + dispatchVerifiedDiscordIngress: () => { + calls.dispatchVerifiedDiscordIngress += 1; + return Promise.resolve({ + ok: true, + value: { + presentation: PRESENTATION, + dispatch: () => Promise.resolve({ ok: true, value: undefined }), + dispose: () => Promise.resolve(), + }, + }); + }, + }; + return { port, calls }; + } + + function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter { + return new ChatRuntimeRouter( + registryWith(['pi']), + boundConversationService, + port, + harness, + 'pi-rpc', + ); + } + function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter { + return new ChatRuntimeRouter( + registryWith([]), + boundConversationService, + port, + harness, + 'legacy', + ); + } + + // completeLegacyRestTurn --------------------------------------------------- + it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' }); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.completeLegacyRestTurn).toBe(0); + }); + it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' }); + expect(result.ok).toBe(true); + expect(calls.completeLegacyRestTurn).toBe(1); + }); + + // prepareLegacySocketTurn -------------------------------------------------- + it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).prepareLegacySocketTurn( + ctx(), + { content: 'hello' }, + stream, + ); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.prepareLegacySocketTurn).toBe(0); + }); + it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).prepareLegacySocketTurn( + ctx(), + { content: 'hello' }, + stream, + ); + expect(result.ok).toBe(true); + expect(calls.prepareLegacySocketTurn).toBe(1); + }); + + // setLegacyThinking (sync) ------------------------------------------------- + it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).setLegacyThinking(ctx(), 'high'); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.setLegacyThinking).toBe(0); + }); + it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).setLegacyThinking(ctx(), 'high'); + expect(result.ok).toBe(true); + expect(calls.setLegacyThinking).toBe(1); + }); + + // abortLegacyTurn ---------------------------------------------------------- + it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await piRouter(port).abortLegacyTurn(ctx()); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.abortLegacyTurn).toBe(0); + }); + it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const result = await legacyRouter(port).abortLegacyTurn(ctx()); + expect(result.ok).toBe(true); + expect(calls.abortLegacyTurn).toBe(1); + }); + + // applyLegacyModelOverride (sync) ------------------------------------------ + it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x'); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.applyLegacyModelOverride).toBe(0); + }); + it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x'); + expect(result.ok).toBe(true); + expect(calls.applyLegacyModelOverride).toBe(1); + }); + + // readLegacySessionPresentation (sync) ------------------------------------- + it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = piRouter(port).readLegacySessionPresentation(ctx()); + expect(result).toEqual(RUNTIME_UNSUPPORTED); + expect(calls.readLegacySessionPresentation).toBe(0); + }); + it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => { + const { port, calls } = recordingEmbeddedPort(); + const result = legacyRouter(port).readLegacySessionPresentation(ctx()); + expect(result.ok).toBe(true); + expect(calls.readLegacySessionPresentation).toBe(1); + }); + + // dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) --------- + it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => { + const { port, calls } = recordingEmbeddedPort(); + const discordCtx = ctx() as unknown as Parameters< + ChatRuntimeRouter['dispatchVerifiedDiscordIngress'] + >[0]; + const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream); + expect(result.ok).toBe(true); + expect(calls.dispatchVerifiedDiscordIngress).toBe(1); + }); +}); + +/** + * Task Five, Step Two — group 1 (real Nest module-graph readiness). + * + * The unit suite above constructs the router directly. This group drives the SAME contract + * through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting + * idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry` + * via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest + * lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose + * `onModuleInit` throws a generic Error, so: + * - readiness cases fail because the graph never comes up (init rejects), and + * - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed + * `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that + * "throws anything" cannot mask these greens. + * The router is NOT wired into a production module yet, so it is provided here via a factory + * over the real registry token. Importing the real `ChatModule` bare is deliberately avoided: + * it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a + * collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router. + */ +describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => { + async function bootRouterGraph( + mode: ChatRuntimeMode, + opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding }, + ) { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + providers: [ + { + provide: ChatRuntimeRouter, + useFactory: (registry: HarnessRegistry) => + new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode), + inject: [HARNESS_REGISTRY], + }, + ], + }) + // The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern, + // never exercised here); stub it so the graph resolves. The registry is NOT overridden — + // group 1 asserts against the genuine production HarnessRegistry. + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + // Resolve the production registry singleton and register the requested adapters ON IT, so + // the router (which injects the same singleton) sees them when its lifecycle hook runs. + const registry = moduleRef.get(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..08f18f95 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, @@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => { }); }); }); + +/** + * Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so + * embedded slash-commands (/model, /agent, and every other non-audited command) are fixed + * "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent + * fall-through to embedded execution. Only runtime-independent audited system commands + * (/reload) pass through as a positive control, and the approval path stays runtime-independent. + * The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so + * a fence bypass surfaces as a thrown error rather than a silent embedded dispatch. + */ +function buildPiRpcGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + const piRpcRouter = { + runtimeMode: 'pi-rpc' as const, + onModuleInit: () => { + throw new Error('chat runtime router must not initialise on the pi-rpc command path'); + }, + get active(): never { + throw new Error('chat runtime must not be resolved on the pi-rpc command path'); + }, + }; + return new ChatGateway( + piRpcRouter as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => { + const UNSUPPORTED = 'Slash commands are not available on this deployment.'; + + it.each(['model', 'agent', 'gc'])( + 'fails /%s closed before the executor under pi-rpc (execute never called)', + async (command): Promise => { + const commandExecutor = { + execute: vi + .fn() + .mockResolvedValue({ command, conversationId: 'conversation-1', success: true }), + createApproval: vi.fn(), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, { + command, + conversationId: 'conversation-1', + }); + + expect(commandExecutor.execute).toHaveBeenCalledTimes(0); + expect(client.emit).toHaveBeenCalledWith('command:result', { + command, + conversationId: 'conversation-1', + success: false, + message: UNSUPPORTED, + }); + }, + ); + + it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise => { + const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true }; + const commandExecutor = { + execute: vi.fn().mockResolvedValue(reloadResult), + createApproval: vi.fn(), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, { + command: 'reload', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.execute).toHaveBeenCalledTimes(1); + expect(commandExecutor.execute).toHaveBeenCalledWith( + { command: 'reload', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult); + }); + + it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildPiRpcGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith( + 'command:approval', + expect.objectContaining({ success: true, approvalId: 'approval-1' }), + ); + }); +}); 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..b086d135 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; } /** @@ -78,6 +87,14 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +/** + * Task 5 (G3): commands whose effect is runtime-independent — they operate on gateway/system + * state rather than an embedded chat session — and therefore stay available under pi-rpc. Every + * other command is an embedded-session command and is fixed-"unsupported" under pi-rpc, failing + * closed before the executor. Kept as an explicit allowlist so adding a runtime-independent + * command is a deliberate edit, never an accidental fall-through. + */ +const RUNTIME_INDEPENDENT_COMMANDS: ReadonlySet = new Set(['reload']); const MAX_REDACTION_BUFFER_LENGTH = 8_192; const MAX_CHANNEL_ATTACHMENTS = 10; const MAX_ATTACHMENT_METADATA_BYTES = 16_384; @@ -218,12 +235,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, @@ -261,18 +281,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); + + // Send-protocol advertisement (Task 5): a conversation id or harness selection never proves the + // connected Gateway handles a given wire event, so after BetterAuth authentication and + // user/session assignment advertise — exactly once, bound to this connection — which send event + // the browser may use. Legacy mode handles `message` (`legacy-message`); `pi-rpc` fails that + // handler closed and its authenticated `turn:send` handler lands in Task 15, so it advertises + // `unavailable` and never `turn-send`. Capability is routing information, never authorization. + client.emit('chat:send-capability', { + protocol: this.runtime.runtimeMode === 'pi-rpc' ? 'unavailable' : 'legacy-message', + connectionId: client.id, + }); } handleDisconnect(client: Socket): void { 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 +331,123 @@ 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 suppliedConversationId = data.conversationId; + const conversationId = suppliedConversationId ?? 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}` : ''}`, - ); - - // 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), - ); + // Durable ownership admission BEFORE any runtime/listener/channel effect (security fix, + // finding 1). A browser-supplied conversation id must resolve to THIS socket's own durable + // owner; a missing row and a row owned by another user both fail closed here, so runtime state + // is never allocated under an unowned conversation. A send that omits the id is the distinct + // server-minted-new path: the durable record is created first and a creation failure fails + // closed. Both rejections collapse to conversation_unavailable with zero downstream effects. + if ( + !(await this.admitBrowserConversation(suppliedConversationId, conversationId, scope.userId)) + ) { client.emit('error', { conversationId, + code: 'conversation_unavailable', + retryable: false, + error: 'That conversation is not available.', + }); + return; + } + + 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, + ); + 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); - } - - // M5-007: Count the user message - this.agentService.recordMessage(conversationId); - - // 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( + this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); + // Persist the user turn BEFORE acknowledging or dispatching. If persistence fails, abort the + // turn: tear down the just-prepared lease and surface the failure — never ack-then-lose. + const persisted = await this.persistUserMessage( conversationId, - (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }, - scope, + scope.userId, + data.content, + data.attachments, ); - - // 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 } : {}), - }); - } + if (!persisted) { + await this.disposeExistingSession(key); + client.emit('error', { + conversationId, + code: 'persist_failed', + retryable: true, + error: 'Your message could not be saved. Please try again.', + }); + return; } - // Send acknowledgment - client.emit('message:ack', { - conversationId, - messageId: uuid(), - ...(correlationId - ? { - correlationId, - discordMessageId: discordIngress?.messageId, - discordUserId: discordIngress?.userId, - } - : {}), - }); + client.emit('session:info', { conversationId, ...prepared.value.presentation }); + client.emit('message:ack', { conversationId, messageId: uuid() }); - // 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 +455,249 @@ 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, 'send', false); + 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 } : {}), + }), + ); + + // Reconcile the verified binding against the durable agent record BEFORE claiming the message + // id (security fix, finding 3). The configured agent must exist and its record must match the + // binding EXACTLY on BOTH id and name — a record whose id differs from the binding's + // agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A + // missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT + // consuming the replay claim, so a corrected retry is still admitted. The branded identity is + // derived from the record (id + name), never from the raw binding strings — an unreconciled + // binding must not execute a default or different embedded agent under a verified label. + let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string }; + try { + const record = await this.brain.agents.findById(binding.agentConfigId); + if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) { + this.logger.warn( + `Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`, + ); + return; + } + configuredAgent = { agentConfigId: record.id, instanceId: record.name }; + } catch (err) { + this.logger.error( + `Discord configured-agent reconciliation failed binding=${binding.agentConfigId}`, + err instanceof Error ? err.stack : String(err), + ); + return; + } + + // Atomic replay claim LAST — after the configured-identity, binding, forced-scope, and + // attachment checks above have all passed, and immediately before the first effect (existing + // session teardown + dispatch). An envelope rejected by any earlier gate consumes no claim, so + // a corrected byte-identical retry dispatches once; once a turn commits here, a true duplicate + // finds the claim taken and fails closed with no additional dispatch/persist/ack. + if (!this.discordReplayProtector.claim(ingress.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${ingress.messageId} correlation=${ingress.correlationId}`, + ); + return; + } + + 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 is the record reconciled above (finding 3): the embedded runtime rechecks + // scope, refuses to reuse a same-scope session under a different identity, and mints under this + // exact reconciled identity. + const context = verifyDiscordIngress({ + conversationId, + scope, + configuredAgent, + 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); + // Persist BEFORE acknowledging or dispatching; on persistence failure abort the verified turn + // (tear down the lease, surface the error) rather than ack-then-lose the Discord message. + const persisted = await this.persistUserMessage( + conversationId, + scope.userId, + ingress.content, + attachments, + { + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + }, + ); + if (!persisted) { + await this.disposeExistingSession(key); + client.emit('error', { + conversationId, + code: 'persist_failed', + retryable: true, + correlationId: ingress.correlationId, + discordMessageId: ingress.messageId, + discordUserId: ingress.userId, + error: 'Your message could not be saved. Please try again.', + }); + return; + } + + 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 true; + await this.ensureConversation(conversationId, userId); + try { + const saved = 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, + ); + // A nullish result is durable persistence failure, not success: `addMessage` returns + // undefined when the parent conversation is missing or owned by another user, inserting no + // row. Treat it exactly like a thrown error so the caller tears down the lease and surfaces + // `persist_failed` — never ack/dispatch/prompt on a turn whose user message was not stored. + if (saved === undefined || saved === null) { + this.logger.error( + `User message not persisted for conversation=${conversationId}: no durable record (missing or foreign conversation owner)`, + ); + return false; + } + return true; + } catch (err) { + this.logger.error( + `Failed to persist user message for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return false; + } + } + @SubscribeMessage('set:thinking') handleSetThinking( @ConnectedSocket() client: Socket, @@ -603,37 +712,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 +757,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') @@ -690,6 +787,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } + // Task 5 (G3): under pi-rpc there is no embedded chat session, so embedded slash-commands are + // unsupported. Fail closed BEFORE the executor — never fall back to embedded execution — while + // runtime-independent audited system commands (e.g. /reload) still pass through. + if ( + this.runtime.runtimeMode === 'pi-rpc' && + !RUNTIME_INDEPENDENT_COMMANDS.has(payload.command) + ) { + client.emit('command:result', { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Slash commands are not available on this deployment.', + }); + return; + } + const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } @@ -741,7 +854,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 +880,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 } : {}), }; @@ -965,6 +1076,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client: Socket, envelope: DiscordIngressEnvelope, operation: 'send' | 'approve' | 'stop' = 'send', + claimReplay = true, ): DiscordIngressPayload | null { const payload = verifyDiscordIngressEnvelope( envelope, @@ -998,7 +1110,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); return null; } - if (!this.discordReplayProtector.claim(payload.messageId)) { + // The SEND path passes `claimReplay: false` and claims the message itself only after the + // configured-identity, binding, forced-scope, and attachment checks succeed — immediately + // before its first effect — so a SEND rejected by one of those later gates burns no claim and + // a corrected retry is not mistaken for a replay. The approve/stop paths have no such + // post-resolve gates, so they claim here, at the moment the envelope is fully verified. + if (claimReplay && !this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, ); @@ -1027,6 +1144,42 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa .filter((id: string): boolean => id.length > 0); } + /** + * Durable ownership admission for a browser send, run BEFORE any runtime/listener/channel effect + * (security fix, finding 1). + * + * A supplied conversation id must durably resolve to this socket's own user: `findById` scopes by + * owner, so a missing row and a row owned by another user both return undefined and admission + * fails (returns false) with zero runtime effect. A send that omits the id + * (`suppliedConversationId === undefined`) is the server-minted-new path — the durable record is + * created first and any creation failure fails closed. Never allocate runtime under a conversation + * this socket does not own or could not create. + */ + private async admitBrowserConversation( + suppliedConversationId: string | undefined, + conversationId: string, + userId: string, + ): Promise { + try { + if (suppliedConversationId !== undefined) { + const owned = await this.brain.conversations.findById(suppliedConversationId, userId); + return owned !== undefined; + } + // Minting a new conversation must actually yield the durable record we asked for before any + // runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that + // is not this exact id owned by this user, is a persistence failure — fail closed so the caller + // never dispatches/persists against an unpersisted or mis-scoped conversation. + const created = await this.brain.conversations.create({ id: conversationId, userId }); + return created != null && created.id === conversationId && created.userId === userId; + } catch (err) { + this.logger.error( + `Conversation admission failed for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return false; + } + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); @@ -1044,45 +1197,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 +1365,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 +1380,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 +1396,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 +1477,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 +1485,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 +1518,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..17a0146c 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], - exports: [ChatGateway], + providers: [ + ChatGateway, + EmbeddedChatRuntime, + { + provide: HarnessChatRuntime, + useFactory: (conversationService: HarnessConversationServiceBinding) => + new HarnessChatRuntime(conversationService as HarnessConversationService), + inject: [HARNESS_CONVERSATION_SERVICE], + }, + { + provide: ChatRuntimeRouter, + useFactory: ( + registry: HarnessRegistry, + conversationService: HarnessConversationServiceBinding, + embedded: EmbeddedChatRuntime, + harness: HarnessChatRuntime, + ) => new ChatRuntimeRouter(registry, conversationService, embedded, harness), + inject: [ + HARNESS_REGISTRY, + HARNESS_CONVERSATION_SERVICE, + EmbeddedChatRuntime, + HarnessChatRuntime, + ], + }, + ], + exports: [ChatGateway, ChatRuntimeRouter], }) export class ChatModule {} 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..fa6260a2 --- /dev/null +++ b/apps/gateway/src/chat/embedded-chat.runtime.ts @@ -0,0 +1,532 @@ +import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { AgentService, type AgentSession } from '../agent/agent.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import type { + ChatRuntime, + LegacyBrowserMessagePayload, + LegacyEmbeddedChatPort, + LegacyRuntimeEvent, + LegacyRuntimeResult, + LegacySessionPresentation, + LegacySocketTurnLease, + LegacyUsage, + OwnedConversationContext, + VerifiedDiscordIngressContext, + VerifiedDiscordTurnLease, + LegacyRuntimeStream, +} from './chat-runtime.js'; + +/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */ +const REST_TURN_TIMEOUT_MS = 120_000; + +/** + * The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}. + * + * It owns the embedded in-process execution path — the `AgentService` stack that the + * `ChatController` and `ChatGateway` drove directly before Task Five. Once the + * {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser + * HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so + * neither the controller nor the gateway retains `AgentService`, `piSession`, session, + * listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by + * `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation + * collapses to `conversation_unavailable` and never throws out of the port. + */ +@Injectable() +export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort { + readonly kind = 'embedded' as const; + private readonly logger = new Logger(EmbeddedChatRuntime.name); + + constructor(readonly agentService: AgentService) {} + + // ------------------------------------------------------------------------- + // Legacy REST completion (op A) + // ------------------------------------------------------------------------- + + async completeLegacyRestTurn( + context: OwnedConversationContext, + input: Readonly<{ content: string }>, + ): Promise< + LegacyRuntimeResult> + > { + const scope = toScope(context.scope); + const { conversationId } = context; + + const resolved = await this.resolveOrCreate(conversationId, scope, {}); + if (!resolved.ok) return resolved; + + let responseText = ''; + let timer: ReturnType | undefined; + let detach: (() => void) | undefined; + let disposed = false; + // One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and + // detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt + // rejection fires first. Without this, a prompt() rejection surfaced through the catch below + // would return while leaving the listener attached (free to consume a later turn's events) and + // the 120s timer live (its rejection later going unobserved). + const dispose = (): void => { + if (disposed) return; + disposed = true; + if (timer !== undefined) clearTimeout(timer); + detach?.(); + }; + const done = new Promise((resolve, reject) => { + timer = setTimeout(() => { + dispose(); + reject(new Error('Agent response timed out')); + }, REST_TURN_TIMEOUT_MS); + + detach = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + if ( + event.type === 'message_update' && + event.assistantMessageEvent.type === 'text_delta' + ) { + responseText += event.assistantMessageEvent.delta; + } + if (event.type === 'agent_end') { + dispose(); + resolve(); + } + }, + scope, + ); + }); + + // Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the + // timeout unobservable until prompt settled (a hung prompt could never time out) and, worse, + // let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection + // window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole + // turn even while prompt is pending, and neither promise can reject unobserved. Success still + // requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential + // await). The idempotent dispose() clears the timer + detaches on whichever settles first. + const prompting = this.agentService.prompt(conversationId, input.content, scope); + try { + await Promise.all([prompting, done]); + } catch (err) { + dispose(); + const message = err instanceof Error ? err.message : String(err); + if (message.includes('timed out')) { + return { ok: false, code: 'timeout', retryable: true }; + } + this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message); + return { ok: false, code: 'operation_failed', retryable: false }; + } + + const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation; + return { ok: true, value: { text: responseText, presentation } }; + } + + // ------------------------------------------------------------------------- + // Legacy Socket streaming (op B) + // ------------------------------------------------------------------------- + + async prepareLegacySocketTurn( + context: OwnedConversationContext, + input: LegacyBrowserMessagePayload, + stream: LegacyRuntimeStream, + ): Promise> { + const scope = toScope(context.scope); + const { conversationId } = context; + + const resolved = await this.resolveOrCreate(conversationId, scope, { + ...(input.provider ? { provider: input.provider } : {}), + ...(input.modelId ? { modelId: input.modelId } : {}), + ...(input.agentId ? { agentConfigId: input.agentId } : {}), + }); + if (!resolved.ok) return resolved; + + let detach: () => void; + try { + detach = this.subscribe(conversationId, scope, stream); + } catch (err) { + // A partial listener/channel setup rolled itself back inside subscribe(); surface a total + // safe failure instead of throwing out of the port. Retryable — the attach is transient. + this.logger.error( + `Embedded socket subscription failed for conversation=${conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + + return { + ok: true, + value: this.buildLease( + conversationId, + scope, + input.content, + input.attachments, + detach, + resolved.presentation, + ), + }; + } + + // ------------------------------------------------------------------------- + // Thinking level (op C) — synchronous, total + // ------------------------------------------------------------------------- + + setLegacyThinking( + context: OwnedConversationContext, + level: string, + ): LegacyRuntimeResult { + 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 }, + { + agentConfigId: context.configuredAgent.agentConfigId, + instanceId: context.configuredAgent.instanceId, + }, + ); + if (!resolved.ok) return resolved; + + let detach: () => void; + try { + detach = this.subscribe(conversationId, scope, stream); + } catch (err) { + // A partial listener/channel setup rolled itself back inside subscribe(); surface a total + // safe failure instead of throwing out of the port. Retryable — the attach is transient. + this.logger.error( + `Embedded Discord subscription failed for conversation=${conversationId}`, + err instanceof Error ? err.message : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + + return { + ok: true, + value: this.buildLease( + conversationId, + scope, + context.content, + context.attachments, + detach, + resolved.presentation, + ), + }; + } + + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + /** + * Resolves the owned session, creating it on first use. Ownership/scope rejections + * (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation + * failure surfaces as the retryable `runtime_unavailable`. On success returns the + * session presentation so callers avoid a redundant `getSession`. + */ + private async resolveOrCreate( + conversationId: string, + scope: ActorTenantScope, + extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>, + expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>, + ): Promise< + | { readonly ok: true; readonly presentation: LegacySessionPresentation } + | Exclude, { ok: true }> + > { + // A verified-Discord turn may only run under a session whose configured identity matches the + // reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused + // pre-existing session AND a freshly created one: a session carrying a different configured + // agent — however it arose — is rejected rather than executed under the verified label, so we + // never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes + // no expectedAgent and skips the check. + const identityMatches = (candidate: AgentSession): boolean => + expectedAgent === undefined || + (candidate.agentConfigId === expectedAgent.agentConfigId && + candidate.agentName === expectedAgent.instanceId); + + let session = this.agentService.getSession(conversationId, scope); + if (session && !identityMatches(session)) { + // Reused same-scope session minted under a different configured identity — reject with zero + // effects rather than dispatch a verified turn onto a foreign agent's session. + return CONVERSATION_UNAVAILABLE; + } + if (!session) { + try { + session = await this.agentService.createSession(conversationId, { + userId: scope.userId, + tenantId: scope.tenantId, + ...extraOptions, + }); + } catch (err) { + if (err instanceof ForbiddenException || err instanceof NotFoundException) { + return CONVERSATION_UNAVAILABLE; + } + this.logger.error( + `Embedded session creation failed for conversation=${conversationId}`, + err instanceof Error ? err.stack : String(err), + ); + return { ok: false, code: 'runtime_unavailable', retryable: true }; + } + // The just-created session must ALSO carry the reconciled identity before any effect. A + // createSession that returns a session under a different configured agent (misconfiguration + // or a substituted factory) is rejected here, before subscribe/persist/ack/prompt. + if (!identityMatches(session)) { + return CONVERSATION_UNAVAILABLE; + } + } + return { ok: true, presentation: this.presentationForSession(session) }; + } + + /** Installs a normalizing event listener that forwards to the server-owned stream. */ + private subscribe( + conversationId: string, + scope: ActorTenantScope, + stream: LegacyRuntimeStream, + ): () => void { + const unsubscribe = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + const normalized = this.normalizeEvent(conversationId, scope, event); + if (normalized) stream.onEvent(normalized); + }, + scope, + ); + try { + this.agentService.addChannel(conversationId, stream.channelId, scope); + } catch (err) { + // Partial setup: the listener was acquired but the channel attach failed. Roll back + // exactly what was acquired (the listener) before the failure escapes, so no leaked + // subscription survives; the caller converts the rethrow into a total safe failure. + try { + unsubscribe(); + } catch { + /* idempotent teardown */ + } + throw err; + } + return () => { + try { + unsubscribe(); + } catch { + /* idempotent teardown */ + } + try { + this.agentService.removeChannel(conversationId, stream.channelId, scope); + } catch { + /* idempotent teardown */ + } + }; + } + + /** Builds an atomically one-shot, scope-rechecking dispatch lease. */ + private buildLease( + conversationId: string, + scope: ActorTenantScope, + content: string, + attachments: VerifiedDiscordIngressContext['attachments'], + detach: () => void, + presentation: LegacySessionPresentation, + ): LegacySocketTurnLease & VerifiedDiscordTurnLease { + let dispatched = false; + let disposed = false; + return { + presentation, + dispatch: async (): Promise> => { + 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..d95355f7 --- /dev/null +++ b/apps/gateway/src/conversations/conversations-harness-fence.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChatRuntimeMode } from '../chat/chat-runtime.js'; +import { ConversationsController } from './conversations.controller.js'; + +/** + * Task 5 harness fence for the conversations REST write path. + * + * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the + * legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a + * fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write. + * Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`. + * + * Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution, + * injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from + * `process.env` at request time. The two "env is flipped after construction" tests below are the + * load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn + * RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`. + */ +const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; +const USER = { id: 'user-1' }; + +function sendMessageDto() { + return { + role: 'user' as const, + content: 'hello from the legacy REST write path', + metadata: undefined, + }; +} + +function brainWithMessageSpy() { + const addMessage = vi.fn().mockResolvedValue({ + id: 'message-1', + conversationId: CONVERSATION_ID, + role: 'user', + content: 'hello from the legacy REST write path', + }); + return { + brain: { conversations: { addMessage } } as never, + addMessage, + }; +} + +/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */ +function routerFixedTo(mode: ChatRuntimeMode) { + return { runtimeMode: mode }; +} + +let priorMode: string | undefined; + +describe('conversations REST write path — Task 5 harness fence', () => { + beforeEach(() => { + priorMode = process.env['CHAT_HARNESS_RUNTIME']; + }); + + afterEach(() => { + if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME']; + else process.env['CHAT_HARNESS_RUNTIME'] = priorMode; + }); + + it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => { + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); + + await expect( + controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), + ).rejects.toMatchObject({ code: 'runtime_unsupported' }); + + // Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be + // written, so no duplicate message can be produced. + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('writes through the repository when the router resolved legacy (GREEN control)', async () => { + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('legacy')); + + const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); + + expect(addMessage).toHaveBeenCalledWith( + { + conversationId: CONVERSATION_ID, + role: 'user', + content: 'hello from the legacy REST write path', + metadata: undefined, + }, + USER.id, + ); + expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID }); + }); + + it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => { + // The runtime mode is fixed at module init. A later env mutation must not reopen the fence: + // a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('pi-rpc')); + + await expect( + controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER), + ).rejects.toMatchObject({ code: 'runtime_unsupported' }); + + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => { + // Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a + // request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write. + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + const { brain, addMessage } = brainWithMessageSpy(); + const controller = new ConversationsController(brain, routerFixedTo('legacy')); + + await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER); + + expect(addMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/gateway/src/conversations/conversations.controller.ts b/apps/gateway/src/conversations/conversations.controller.ts index 69dc8697..b6eaf480 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 { ChatRuntimeRouter } from '../chat/chat-runtime-router.js'; import { CreateConversationDto, UpdateConversationDto, @@ -26,10 +28,41 @@ import { SearchMessagesDto, } from './conversations.dto.js'; +/** + * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the + * legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before + * the repository is touched — never a duplicate write. The `code` field is exposed at the top level + * so callers can discriminate the refusal while the 503 status carries the browser-safe surface. + */ +class HarnessRuntimeWriteUnsupportedException extends HttpException { + readonly code = 'runtime_unsupported' as const; + + constructor() { + super( + { + code: 'runtime_unsupported', + message: + 'Conversation message writes are handled by the harness runtime on this deployment.', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} + @Controller('api/conversations') @UseGuards(AuthGuard) export class ConversationsController { - constructor(@Inject(BRAIN) private readonly brain: Brain) {} + /** + * `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3). + * The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at + * request time — a single source of truth, so the controller cannot disagree with the router + * about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode` + * so this class depends on nothing else the router exposes. + */ + constructor( + @Inject(BRAIN) private readonly brain: Brain, + @Inject(ChatRuntimeRouter) private readonly router: Pick, + ) {} @Get() async list(@CurrentUser() user: { id: string }) { @@ -94,6 +127,13 @@ export class ConversationsController { @Body() dto: SendMessageDto, @CurrentUser() user: { id: string }, ) { + // Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the + // harness path owns persistence there, so a direct write would duplicate the message. The mode + // comes from the router's init-time resolution, not a request-time env read. + if (this.router.runtimeMode === 'pi-rpc') { + throw new HarnessRuntimeWriteUnsupportedException(); + } + const message = await this.brain.conversations.addMessage( { conversationId: id, diff --git a/apps/gateway/src/conversations/conversations.module.ts b/apps/gateway/src/conversations/conversations.module.ts index 19f3912f..ac56d566 100644 --- a/apps/gateway/src/conversations/conversations.module.ts +++ b/apps/gateway/src/conversations/conversations.module.ts @@ -1,7 +1,14 @@ import { Module } from '@nestjs/common'; +import { ChatModule } from '../chat/chat.module.js'; import { ConversationsController } from './conversations.controller.js'; +/** + * Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into + * {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the + * router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit. + */ @Module({ + imports: [ChatModule], controllers: [ConversationsController], }) export class ConversationsModule {} 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..eef7ceec 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,575 @@ describe('Discord ingress security', () => { expect.objectContaining({ agentConfigId: 'agent-config-orion' }), ); expect(routingEngine.resolve).not.toHaveBeenCalled(); + // Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must + // never touch it — the create path stays on the embedded runtime. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-replay', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + // One fully-valid signed envelope; the replay reuses the SAME object (same messageId). + const envelope = ingressEnvelope('verified once', 'discord-replay-001', { + conversationId: 'Nova:discord:channel-001', + }); + + // First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns + // null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + // The harness runtime is never touched on either delivery. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-claim-ordering', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + // A single fully-valid signed envelope, reused byte-for-byte across all three deliveries. + const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', { + conversationId: 'Nova:discord:channel-001', + }); + + // (1) Configured service identity is MISSING. The envelope is validly signed and passes the + // binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim + // or effect. If the claim fires ahead of that gate, this delivery silently burns the + // replay claim for `discord-order-001` even though nothing dispatched. + delete process.env['DISCORD_SERVICE_USER_ID']; + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because + // step (1) consumed no claim, this corrected retry claims once and runs the full embedded + // dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1), + // so this retry is dropped as a replay and never dispatches — the RED this test drives.) + process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2) + // blocks it, so every effect remains at exactly one. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + // The durable agent record does not reconcile on the first delivery (its name no longer matches + // the verified binding's instance id), then reconciles cleanly on the corrected retry. + const findAgent = vi + .fn() + .mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' }) + .mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' }); + const brain = { + agents: { findById: findAgent }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-reconcile', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + const envelope = ingressEnvelope( + 'verified once with stale agent record', + 'discord-reconcile-001', + { + conversationId: 'Nova:discord:channel-001', + }, + ); + + // (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn + // and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims + // once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead + // of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.) + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after the committed turn stays fail-closed. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + // A live session already exists for this conversation/scope, but it was minted under a DIFFERENT + // configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would + // execute one agent's turn under another agent's verified label — the reuse guard must refuse it. + const foreignIdentitySession = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-orion', + agentName: 'Orion', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const prompt = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue(foreignIdentitySession); + const agentService = { + getSession: vi.fn().mockReturnValue(foreignIdentitySession), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-identity-swap', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + // Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal. + expect(prompt).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + // The name matches the verified binding, but the record's own id is a DIFFERENT agent config — + // an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery, + // then admit the corrected record whose id matches the binding. + const findAgent = vi + .fn() + .mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' }) + .mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' }); + const brain = { + agents: { findById: findAgent }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-reconcile-id', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + const envelope = ingressEnvelope( + 'verified once with aliased agent id', + 'discord-reconcile-id-001', + { + conversationId: 'Nova:discord:channel-001', + }, + ); + + // (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the + // turn BEFORE the replay claim, so nothing dispatches and the claim stays available. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) The record now reconciles on both id and name; because step (1) took no claim, this + // byte-identical retry claims once and runs the full embedded dispatch exactly once. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after the committed turn stays fail-closed. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + // No live session exists for this scope, so the runtime MINTS one — but createSession returns a + // session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova). + // The post-create identity recheck must refuse it rather than dispatch one agent's turn under + // another agent's verified label. (The existing reuse test covers the getSession path; this + // covers the createSession path scrappy flagged as unvalidated.) + const mintedForeignSession = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-orion', + agentName: 'Orion', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const prompt = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue(mintedForeignSession); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-postcreate-mismatch', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('mint under a different identity', 'discord-postcreate-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + // The freshly minted session failed the post-create identity recheck: refused with a typed + // error, no prompt, no persist, no ack. + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); }); it('retains validated persisted attachments in resumed conversation history', async () => { @@ -593,11 +1220,16 @@ describe('Discord ingress security', () => { it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => { configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; const prompt = vi.fn().mockResolvedValue(undefined); - const addMessage = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); const session = { provider: 'test-provider', modelId: 'test-model', + // The reused embedded session carries the SAME reconciled identity as the verified binding, + // so the finding-3 session-reuse guard admits it rather than refusing an identity swap. + agentConfigId: 'agent-config-nova', + agentName: 'Nova', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], @@ -611,6 +1243,7 @@ describe('Discord ingress security', () => { prompt, }; const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, conversations: { findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), create: vi.fn().mockResolvedValue(undefined), @@ -618,8 +1251,9 @@ describe('Discord ingress security', () => { addMessage, }, }; + const harnessConversations = { append: vi.fn() }; const gateway = new ChatGateway( - agentService as never, + piRpcRouterFronting(agentService, harnessConversations) as never, {} as never, brain as never, {} as never, @@ -667,6 +1301,66 @@ describe('Discord ingress security', () => { }), 'discord-service', ); + // The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that + // the router resolved as `active` is never reached. + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => { + // Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is + // set only on a valid service-token handshake), so it cannot forge the trusted Discord path by + // emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal + // (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither + // the forced Discord service scope, the verified Discord operation, the embedded runtime, nor + // the harness. There is no dedicated socket event for verified ingress — the only ingress + // surface is the generic `message` handler, and a non-service client is refused there. + // + // RED today: a non-service client emitting an envelope-shaped payload falls to the browser + // branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no + // typed refusal emitted — so the refusal assertion fails. Collection and construction succeed; + // the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch. + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn(), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const routingEngine = { resolve: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + { conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never, + {} as never, + {} as never, + routingEngine as never, + ); + const client = { + id: 'browser-forging-discord', + data: { discordService: false }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('forged from a browser', 'browser-forgery-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + const refusal = client.emit.mock.calls.find( + ([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported', + ); + expect(refusal).toBeDefined(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(harnessConversations.append).not.toHaveBeenCalled(); + expect(routingEngine.resolve).not.toHaveBeenCalled(); }); it('accepts a thread message through its allowed bound parent channel', () => { diff --git a/apps/web/src/lib/chat-contract.ts b/apps/web/src/lib/chat-contract.ts index f8da2fc3..e6763123 100644 --- a/apps/web/src/lib/chat-contract.ts +++ b/apps/web/src/lib/chat-contract.ts @@ -10,11 +10,16 @@ import type { AgentTextPayload, AgentThinkingPayload, ChatMessagePayload, + ChatSendCapabilityPayload, + ChatSendProtocol, ClientToServerEvents, CommandDef, CommandManifest, CommandManifestPayload, ErrorPayload, + HarnessSelection, + HarnessTurnAckPayload, + HarnessTurnSendPayload, MessageAckPayload, RoutingDecisionInfo, ServerToClientEvents, @@ -37,11 +42,16 @@ export type { AgentTextPayload, AgentThinkingPayload, ChatMessagePayload, + ChatSendCapabilityPayload, + ChatSendProtocol, ClientToServerEvents, CommandDef, CommandManifest, CommandManifestPayload, ErrorPayload, + HarnessSelection, + HarnessTurnAckPayload, + HarnessTurnSendPayload, MessageAckPayload, RoutingDecisionInfo, ServerToClientEvents, diff --git a/apps/web/src/spa/chat/composer.tsx b/apps/web/src/spa/chat/composer.tsx index 2bd2d033..07ae631d 100644 --- a/apps/web/src/spa/chat/composer.tsx +++ b/apps/web/src/spa/chat/composer.tsx @@ -1,8 +1,9 @@ import { useState, type KeyboardEvent, type ReactElement } from 'react'; +import type { HarnessSelection } from '@/lib/types'; import type { HarnessSelectionValue } from './use-harness-selection'; interface ComposerProps { - onSend: (input: { content: string; provider?: string; modelId?: string }) => void; + onSend: (input: { content: string; selection: HarnessSelection }) => boolean; onStop: () => void; streaming: boolean; /** True from local send time through server turn startup/ack and @@ -44,11 +45,17 @@ export function Composer({ if (busy) return; // Send is gated on a validated, persisted catalog tuple — a draft or unset // selection can never emit, so provider/model never travel as free text. - if (!harness.canSend) return; + if (!harness.canSend || harness.persistedSelection === null) return; const trimmed = content.trim(); if (!trimmed) return; - onSend({ content: trimmed, ...harness.projection }); - setContent(''); + // Pass the validated, persisted selection tuple only. The hook derives the + // wire projection (legacy `message` provider/model, or `turn:send`) from the + // negotiated `chat:send-capability` protocol — never from flat caller input. + const selection = harness.persistedSelection; + const ok = onSend({ content: trimmed, selection }); + // Clear the input only when the send was accepted — a refused turn (e.g. a + // failed idempotency mint) must retain the user's text so it is not lost. + if (ok) setContent(''); } function handleKeyDown(event: KeyboardEvent): void { diff --git a/apps/web/src/spa/chat/test-support/fake-chat-socket.ts b/apps/web/src/spa/chat/test-support/fake-chat-socket.ts index ce80117b..b2f45d08 100644 --- a/apps/web/src/spa/chat/test-support/fake-chat-socket.ts +++ b/apps/web/src/spa/chat/test-support/fake-chat-socket.ts @@ -14,6 +14,10 @@ export interface EmittedEvent { /** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */ export interface FakeChatSocket { connected: boolean; + /** Mirrors socket.io-client's `Socket.id`: the connection identity the server + * echoes in a `chat:send-capability` payload. The generation-bound send + * protocol accepts an advertisement only when `payload.connectionId === id`. */ + id: string; connect(): FakeChatSocket; on(event: K, handler: ServerHandler): FakeChatSocket; off(event: K, handler: ServerHandler): FakeChatSocket; @@ -51,8 +55,10 @@ export function createFakeChatSocket(): { /** Simulates socket.io-client's automatic reconnect of the *same* * instance after a transient disconnect: marks the socket connected again * and fires any handler(s) registered via `socket.on('connect', ...)`, - * without clearing or replacing any listeners. */ - simulateReconnect(): void; + * without clearing or replacing any listeners. A real reconnect is assigned + * a fresh `Socket.id`; pass `nextId` to model that new connection identity + * (defaults to the current id so existing callers are unaffected). */ + simulateReconnect(nextId?: string): void; } { const listeners = new Map void>>(); const emitted: EmittedEvent[] = []; @@ -63,6 +69,7 @@ export function createFakeChatSocket(): { // type-checked against ServerToClientEvents/ClientToServerEvents. const socket = { connected: false, + id: 'socket-a', connect: vi.fn(function connect(this: void) { socket.connected = true; return socket; @@ -105,8 +112,9 @@ export function createFakeChatSocket(): { } } - function simulateReconnect(): void { + function simulateReconnect(nextId: string = socket.id): void { socket.connected = true; + socket.id = nextId; const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent; for (const handler of listeners.get(lifecycleKey) ?? []) { (handler as () => void)(); 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..66aaf826 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 { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types'; import { useChatConnection, type ChatConnectionValue } from './use-chat-connection'; let fake: ReturnType; @@ -33,6 +34,126 @@ function Harness(): null { return null; } +/** + * Task Five, Step Two (web send path) red-first support. These probe the FUTURE + * pi-rpc send contract against the CURRENT implementation, so the desired API is + * expressed here as a localized cast — production types stay untouched until Step + * Three. The reds fail on behaviour (legacy `message` emitted instead of + * `turn:send`; no nested selection; no idempotency key; void return; no + * conversation-id gating), never on a missing module or type. + */ +interface HarnessTurnSendInput { + readonly content: string; + readonly selection: HarnessSelection; +} +type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean; + +function harnessSend(): HarnessSendMessage { + return latest?.actions.sendMessage as unknown as HarnessSendMessage; +} + +/** + * Task Five MAJOR-1 (browser send-protocol negotiation) support. The Gateway + * advertises how this connection may send via a server-to-client-only + * `chat:send-capability` (already part of the typed `ServerToClientEvents` + * contract, so this uses the fake's typed `serverEmit` — no cast); the hook + * holds the advertised protocol and routes `sendMessage` through an exhaustive + * switch on it, never inferring it from conversation/selection. When no listener + * is registered yet (CURRENT impl), the emit is an inert no-op, so the reds + * below fail on BEHAVIOUR — the current send path still infers a protocol and + * emits regardless of any advertisement — not on a missing module or type. + */ +function advertiseCapability(protocol: ChatSendProtocol, connectionId: string): void { + fake.serverEmit('chat:send-capability', { protocol, connectionId }); +} + +/** + * Install a controllable `crypto.randomUUID` on the global crypto object and + * return a restore fn. Uses defineProperty on the instance so it works whether + * or not the native method is configurable (it lives on the prototype, so an own + * property simply shadows it). + */ +function installRandomUUID(fn: () => string): () => void { + const g = globalThis as { crypto?: { randomUUID?: () => string } }; + if (!g.crypto) { + Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} }); + } + const cryptoObj = g.crypto as { randomUUID?: () => string }; + const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID'); + Object.defineProperty(cryptoObj, 'randomUUID', { + configurable: true, + writable: true, + value: fn, + }); + return () => { + if (original) { + Object.defineProperty(cryptoObj, 'randomUUID', original); + } else { + Reflect.deleteProperty(cryptoObj, 'randomUUID'); + } + }; +} + +/** + * Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own + * `undefined` property. The native method lives on `Crypto.prototype`, so a + * bare delete of the (non-existent) own property would leave the inherited + * method visible — the shadow is what actually makes the call site see no + * secure generator. Returns a restore fn. + */ +function removeRandomUUID(): () => void { + const g = globalThis as { crypto?: { randomUUID?: () => string } }; + if (!g.crypto) { + Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} }); + } + const cryptoObj = g.crypto as { randomUUID?: () => string }; + const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID'); + Object.defineProperty(cryptoObj, 'randomUUID', { + configurable: true, + writable: true, + value: undefined, + }); + return () => { + if (original) { + Object.defineProperty(cryptoObj, 'randomUUID', original); + } else { + Reflect.deleteProperty(cryptoObj, 'randomUUID'); + } + }; +} + +/** + * Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface + * and the FUTURE fixed idempotency/rejection notice, expressed as a localized + * read-only view over `state`. Production `ChatConnectionState` gains + * `turnReceipt` at Step Three; the cast keeps production types untouched until + * then, so a success assertion against it fails on BEHAVIOUR (no turn:ack + * handler runs), never on a missing module. `error` already exists on state. + */ +interface HarnessTurnReceiptView { + readonly idempotencyKey: string; + readonly receiptId: string; + readonly selection: HarnessSelection; +} +interface HarnessTurnStateView { + readonly turnReceipt: HarnessTurnReceiptView | null | undefined; + readonly error: string | null; +} +function harnessTurnState(): HarnessTurnStateView { + return latest?.state as unknown as HarnessTurnStateView; +} + +/** + * Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op + * today (the fake iterates an empty handler set), so the group-4 reds fail + * because nothing is surfaced, not because this throws. The event name is cast + * past the compile-time `ServerToClientEvents` contract exactly as the + * `turn:send` client cast is; the typed event map lands at Step Three. + */ +function serverEmitTurnAck(payload: unknown): void { + fake.serverEmitRaw('turn:ack' as unknown as Parameters[0], payload); +} + beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, @@ -67,6 +188,20 @@ afterEach(async () => { }); describe('useChatConnection', () => { + // Task Five MAJOR-1: the send path is PROTOCOL-driven — `sendMessage` routes + // only on the negotiated `chat:send-capability`, never on inferred + // conversation/selection state. These pre-existing cases exercise the legacy + // `message` branch, so the connection is advertised `legacy-message` once here + // (server-to-client, for this exact socket id) after the mount registers its + // listener. Sub-describes that need the pi turn-runtime reset the generation + // and re-advertise `turn-send`; the capability describe resets to the + // unadvertised `unavailable` baseline and drives the protocol itself. + beforeEach(async () => { + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + }); + it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); @@ -344,7 +479,10 @@ describe('useChatConnection', () => { it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => { await act(async () => { - latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' }); + latest?.actions.sendMessage({ + content: 'hello', + selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' }, + }); }); expect(fake.emitted).toContainEqual({ @@ -373,6 +511,408 @@ describe('useChatConnection', () => { }); }); + describe('turn:send harness routing (Task Five, Step Two red-first)', () => { + const selection: HarnessSelection = { + harnessId: 'pi', + providerId: 'anthropic', + modelId: 'claude', + }; + const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + + // The pi turn-runtime routes sends through `turn:send`. Reset the generation + // (clearing the outer `legacy-message` advertisement + first-wins lock) and + // advertise `turn-send` for this exact connection, so every send below takes + // the turn-runtime branch. + beforeEach(async () => { + await act(async () => { + fake.simulateReconnect(); + }); + await act(async () => { + advertiseCapability('turn-send', fake.socket.id); + }); + }); + + async function establishConversation(): Promise { + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + } + + it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => { + const restore = installRandomUUID(() => UUID); + try { + await establishConversation(); + await act(async () => { + harnessSend()({ content: 'hello', selection }); + }); + } finally { + restore(); + } + + const sends = fake.emitted.filter((e) => e.event === 'turn:send'); + expect(sends).toHaveLength(1); + expect(sends[0]?.payload).toEqual({ + conversationId: 'c1', + content: 'hello', + selection, + idempotencyKey: UUID, + }); + // The pi-rpc sender must not fall back to the embedded `message` event. + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + }); + + it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => { + const gen = vi.fn(() => UUID); + const restore = installRandomUUID(gen); + try { + await establishConversation(); + await act(async () => { + harnessSend()({ content: 'first', selection }); + }); + await act(async () => { + harnessSend()({ content: 'second', selection }); + }); + } finally { + restore(); + } + + expect(gen).toHaveBeenCalledTimes(2); + const keys = fake.emitted + .filter((e) => e.event === 'turn:send') + .map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey); + expect(keys).toEqual([UUID, UUID]); + }); + + it('does not send before an active conversation id exists (no first-send auto-create)', async () => { + const restore = installRandomUUID(() => UUID); + let returned: boolean | undefined; + try { + await act(async () => { + returned = harnessSend()({ content: 'too early', selection }); + }); + } finally { + restore(); + } + + expect(returned).toBe(false); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + // Nothing optimistically appended when the send is refused. + expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false); + }); + + it('returns true when it emits and false when the send is refused', async () => { + const restore = installRandomUUID(() => UUID); + let refusedEarly: boolean | undefined; + let acceptedAfter: boolean | undefined; + try { + await act(async () => { + refusedEarly = harnessSend()({ content: 'early', selection }); + }); + await establishConversation(); + await act(async () => { + acceptedAfter = harnessSend()({ content: 'now', selection }); + }); + } finally { + restore(); + } + + expect(refusedEarly).toBe(false); + expect(acceptedAfter).toBe(true); + }); + + it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => { + await establishConversation(); + + const failing = installRandomUUID(() => { + throw new Error('secure random unavailable'); + }); + let firstReturn: boolean | undefined; + try { + await act(async () => { + firstReturn = harnessSend()({ content: 'blocked', selection }); + }); + } finally { + failing(); + } + + expect(firstReturn).toBe(false); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false); + + // The send lock must have been released, so a subsequent valid send works. + const restore = installRandomUUID(() => UUID); + let secondReturn: boolean | undefined; + try { + await act(async () => { + secondReturn = harnessSend()({ content: 'retry', selection }); + }); + } finally { + restore(); + } + + expect(secondReturn).toBe(true); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true); + }); + }); + + describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => { + const selection: HarnessSelection = { + harnessId: 'pi', + providerId: 'anthropic', + modelId: 'claude', + }; + const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + + // turn:ack is the receipt for a `turn:send`, so these establish under the pi + // turn-runtime: reset the generation (clearing the outer `legacy-message` + // advertisement + lock) and advertise `turn-send` for this connection. + beforeEach(async () => { + await act(async () => { + fake.simulateReconnect(); + }); + await act(async () => { + advertiseCapability('turn-send', fake.socket.id); + }); + }); + + // Establish the conversation and send one accepted turn under a controlled + // idempotency key. Returns the crypto restore fn so callers unwind it. + async function establishAndSend(): Promise<() => void> { + const restore = installRandomUUID(() => UUID); + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + await act(async () => { + harnessSend()({ content: 'hello', selection }); + }); + return restore; + } + + it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => { + const restore = await establishAndSend(); + try { + await act(async () => { + serverEmitTurnAck({ + conversationId: 'c1', + idempotencyKey: UUID, + receiptId: 'r1', + selection, + }); + }); + } finally { + restore(); + } + + // RED anchor: no turn:ack handler exists, so nothing is recorded. Green + // only when Step Three echoes the exact tuple back into state — never a + // substituted or fabricated one. + expect(harnessTurnState().turnReceipt).toEqual({ + idempotencyKey: UUID, + receiptId: 'r1', + selection, + }); + }); + + it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => { + const restore = await establishAndSend(); + try { + await act(async () => { + serverEmitTurnAck({ + conversationId: 'c1', + idempotencyKey: UUID, + ok: false, + code: 'runtime_unsupported', + error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef', + }); + }); + } finally { + restore(); + } + + // RED anchor: a rejected ack must surface a visible notice; today no + // handler runs, so state.error stays null. + expect(harnessTurnState().error).toBeTruthy(); + // The raw internal exception text must never reach the browser surface. + expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM'); + expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef'); + // A rejection must not fabricate a success receipt tuple. + expect(harnessTurnState().turnReceipt ?? null).toBeNull(); + }); + + it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => { + const firstRestore = await establishAndSend(); + try { + await act(async () => { + serverEmitTurnAck({ + conversationId: 'c1', + idempotencyKey: UUID, + ok: false, + code: 'runtime_unsupported', + error: 'cause-ALPHA adapter_unavailable', + }); + }); + } finally { + firstRestore(); + } + const firstNotice = harnessTurnState().error; + + // A fresh turn on the same conversation, rejected for a DIFFERENT internal + // reason, must surface the identical fixed notice. + const secondRestore = installRandomUUID(() => UUID); + try { + await act(async () => { + harnessSend()({ content: 'again', selection }); + }); + await act(async () => { + serverEmitTurnAck({ + conversationId: 'c1', + idempotencyKey: UUID, + ok: false, + code: 'runtime_unsupported', + error: 'cause-BRAVO conversation_service_unavailable', + }); + }); + } finally { + secondRestore(); + } + const secondNotice = harnessTurnState().error; + + // RED anchor: both are null today; green requires a single frozen safe + // string surfaced for both distinct internal causes. + expect(firstNotice).toBeTruthy(); + expect(secondNotice).toBeTruthy(); + expect(firstNotice).toBe(secondNotice); + expect(firstNotice ?? '').not.toContain('ALPHA'); + expect(secondNotice ?? '').not.toContain('BRAVO'); + }); + }); + + describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => { + const selection: HarnessSelection = { + harnessId: 'pi', + providerId: 'anthropic', + modelId: 'claude', + }; + const UUID_A = '11111111-1111-4111-8111-111111111111'; + const UUID_B = '22222222-2222-4222-9222-222222222222'; + const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + // The idempotency key is minted only on the pi turn-runtime `turn:send` + // branch: reset the generation (clearing the outer `legacy-message` + // advertisement + lock) and advertise `turn-send` for this connection. + beforeEach(async () => { + await act(async () => { + fake.simulateReconnect(); + }); + await act(async () => { + advertiseCapability('turn-send', fake.socket.id); + }); + }); + + async function establish(): Promise { + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + } + + it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => { + const keys = [UUID_A, UUID_B]; + let call = 0; + const restore = installRandomUUID(() => keys[call++] ?? UUID_A); + try { + await establish(); + await act(async () => { + harnessSend()({ content: 'first', selection }); + }); + await act(async () => { + harnessSend()({ content: 'second', selection }); + }); + } finally { + restore(); + } + + const sent = fake.emitted + .filter((e) => e.event === 'turn:send') + .map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey); + // RED anchor: current sendMessage emits the legacy `message`, so no + // turn:send keys exist at all. + expect(sent).toHaveLength(2); + expect(sent[0]).toMatch(UUID_V4); + expect(sent[1]).toMatch(UUID_V4); + expect(sent[0]).not.toBe(sent[1]); + }); + + it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => { + await establish(); + + const restoreCrypto = removeRandomUUID(); + let firstReturn: boolean | undefined; + try { + await act(async () => { + firstReturn = harnessSend()({ content: 'no-secure-random', selection }); + }); + } finally { + restoreCrypto(); + } + + // RED anchors: a refused send returns false and surfaces a visible notice. + expect(firstReturn).toBe(false); + expect(harnessTurnState().error).toBeTruthy(); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false); + + // The lock released synchronously (no server event needed): a later valid + // send goes through. + const restore = installRandomUUID(() => UUID_A); + let secondReturn: boolean | undefined; + try { + await act(async () => { + secondReturn = harnessSend()({ content: 'recovered', selection }); + }); + } finally { + restore(); + } + expect(secondReturn).toBe(true); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true); + }); + + it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => { + // Case 1: absent. + await establish(); + const restoreAbsent = removeRandomUUID(); + try { + await act(async () => { + harnessSend()({ content: 'absent', selection }); + }); + } finally { + restoreAbsent(); + } + const absentNotice = harnessTurnState().error; + + // Case 2: throws with a distinctive internal message. + const failing = installRandomUUID(() => { + throw new Error('SECURE_RANDOM_BOOM entropy pool drained'); + }); + try { + await act(async () => { + harnessSend()({ content: 'throws', selection }); + }); + } finally { + failing(); + } + const throwNotice = harnessTurnState().error; + + // RED anchor: both are null today. + expect(absentNotice).toBeTruthy(); + expect(throwNotice).toBeTruthy(); + expect(absentNotice).toBe(throwNotice); + // The thrown internal detail must never reach the browser surface. + expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM'); + expect(throwNotice ?? '').not.toContain('entropy pool'); + }); + }); + it('abort emits abort with the active conversationId', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); @@ -684,7 +1224,16 @@ describe('useChatConnection', () => { expect(latest?.state.approvalRequestPending).toBe(false); // The send lock must also be released — a subsequent sendMessage after - // reconnect must not be permanently blocked by the interrupted turn. + // reconnect must not be permanently blocked by the interrupted turn. The + // disconnect also voids the negotiated send protocol (MAJOR-1), so model the + // reconnect handshake — the socket reconnects and the server re-advertises + // how this connection may send — before probing the released lock. + await act(async () => { + fake.simulateReconnect(); + }); + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); await act(async () => { latest?.actions.sendMessage({ content: 'after reconnect' }); }); @@ -1665,4 +2214,319 @@ describe('useChatConnection', () => { } expect(destroySocketMock).toHaveBeenCalledOnce(); }); + + describe('chat:send-capability protocol negotiation (Task Five MAJOR-1, red-first)', () => { + const capSelection: HarnessSelection = { + harnessId: 'pi', + providerId: 'anthropic', + modelId: 'claude', + }; + const UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + // The one fixed, safe user-facing notice the hook must surface (code + // `send_protocol_unavailable`) when a send is attempted on a connection whose + // advertised protocol is `unavailable`/unknown/absent. Contract-frozen string. + const UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.'; + + // These tests each drive the protocol negotiation themselves, so they must + // start from a clean, unadvertised generation. Reconnect resets protocolRef + // to `unavailable` and clears the outer `legacy-message` first-wins lock + // WITHOUT advertising — no client emit, so `fake.emitted` stays empty and the + // "starts unavailable" premise holds. + beforeEach(async () => { + await act(async () => { + fake.simulateReconnect(); + }); + }); + + async function establishConversation(): Promise { + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + } + + function connectCalls(): number { + return (fake.socket.connect as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + } + + it('starts with no advertised protocol: a send is refused, emits nothing, mints no key, and surfaces the fixed unavailable notice', async () => { + // No `chat:send-capability` has arrived, so the connection has not been told + // it may send at all. The current impl infers "selection + no conversation + + // no flat provider/model → return false" but SURFACES NOTHING — the red is + // that the fixed `send_protocol_unavailable` notice is never set. + let uuidCalls = 0; + const restore = installRandomUUID(() => { + uuidCalls += 1; + return UUID; + }); + let returned: boolean | undefined; + try { + await act(async () => { + returned = harnessSend()({ content: 'hi', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(returned).toBe(false); + expect(fake.emitted).toHaveLength(0); + expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE); + // The test's name promises "mints no key": the unavailable branch must not + // reach the idempotency mint at all. Without this assertion a defect that + // mints a key before refusing survives. + expect(uuidCalls).toBe(0); + // ...and no user content may be optimistically appended on refusal. + expect(latest?.state.messages.some((m) => m.text === 'hi')).toBe(false); + }); + + it('legacy-message advertised overrides conversation-inference: an established conversation still routes the legacy message event, never turn:send', async () => { + // Same inputs the inference impl routes to `turn:send` (selection + active + // conversation). The advertised protocol is authoritative: it must emit the + // legacy `message` event instead. Red: current impl emits turn:send. + const restore = installRandomUUID(() => UUID); + try { + await establishConversation(); + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + await act(async () => { + harnessSend()({ content: 'hi', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0); + expect(fake.emitted).toContainEqual({ + event: 'message', + payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' }, + }); + }); + + it('legacy-message advertised with no conversation: derives provider/model from the selection tuple and emits one message', async () => { + // The flat provider/modelId caller inputs are gone; the legacy branch must + // source them from the confirmed persisted selection. Red: current impl + // refuses a bare harness send (selection + no flat fields → return false). + let returned: boolean | undefined; + const restore = installRandomUUID(() => UUID); + try { + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + await act(async () => { + returned = harnessSend()({ content: 'first', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(returned).toBe(true); + expect(fake.emitted).toContainEqual({ + event: 'message', + payload: { + conversationId: undefined, + content: 'first', + provider: 'anthropic', + modelId: 'claude', + }, + }); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + }); + + it('unavailable advertised: refuses even with an active conversation and selection, emits nothing, surfaces the fixed notice', async () => { + // Red: current impl ignores the advertisement and emits turn:send. + let uuidCalls = 0; + const restore = installRandomUUID(() => { + uuidCalls += 1; + return UUID; + }); + let returned: boolean | undefined; + try { + await establishConversation(); + await act(async () => { + advertiseCapability('unavailable', fake.socket.id); + }); + await act(async () => { + returned = harnessSend()({ content: 'nope', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(returned).toBe(false); + expect(fake.emitted).toHaveLength(0); + expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE); + // Refusal must not optimistically append the user's turn to the transcript + // (a distinct leak from the emit): the unavailable branch appends nothing. + expect(latest?.state.messages.some((m) => m.text === 'nope')).toBe(false); + // ...and must not mint an idempotency key on the refused path. + expect(uuidCalls).toBe(0); + }); + + it('ignores an advertisement whose connectionId does not match the socket id: protocol stays unavailable and the send is refused', async () => { + // A capability minted for a different (stale/foreign) connection must never + // arm this one. Red: current impl has no connection-id gate and emits + // turn:send off the inferred path. + const restore = installRandomUUID(() => UUID); + let returned: boolean | undefined; + try { + await establishConversation(); + await act(async () => { + advertiseCapability('legacy-message', 'a-different-connection'); + }); + await act(async () => { + returned = harnessSend()({ content: 'spoof', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(returned).toBe(false); + expect(fake.emitted).toHaveLength(0); + expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE); + }); + + it('accepts only the first advertisement for the generation: a later conflicting protocol is ignored', async () => { + // legacy-message wins; the subsequent turn-send is a replay/conflict and is + // dropped. Red: current impl ignores both and infers turn:send. + const restore = installRandomUUID(() => UUID); + try { + await establishConversation(); + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + await act(async () => { + advertiseCapability('turn-send', fake.socket.id); + }); + await act(async () => { + harnessSend()({ content: 'hi', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0); + expect(fake.emitted).toContainEqual({ + event: 'message', + payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' }, + }); + }); + + it('resets to unavailable on disconnect: a later send is refused and never reconnects the socket', async () => { + // Disconnect voids the advertised protocol for the generation. The send must + // refuse and MUST NOT call socket.connect() to force a reconnection. Red: + // current impl keeps the conversation, infers turn:send, and its turn:send + // branch calls socket.connect() when the socket is disconnected. + const restore = installRandomUUID(() => UUID); + let returned: boolean | undefined; + let connectsDuringSend = 0; + try { + await establishConversation(); + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + await act(async () => { + fake.simulateDisconnect(); + }); + const before = connectCalls(); + await act(async () => { + returned = harnessSend()({ content: 'after-drop', selection: capSelection }); + }); + connectsDuringSend = connectCalls() - before; + } finally { + restore(); + } + + expect(returned).toBe(false); + expect(fake.emitted).toHaveLength(0); + expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE); + expect(connectsDuringSend).toBe(0); + }); + + it('resets on reconnect to a fresh generation: refuses until re-advertised, then honors the new advertisement', async () => { + // A reconnect mints a new Socket.id and a new generation; the prior + // advertisement (bound to the old id) is stale and must not carry over. The + // hook only trusts a fresh advertisement for the new connection. Red: + // current impl has no connect listener and keeps inferring turn:send. + const restore = installRandomUUID(() => UUID); + let refusedAfterReconnect: boolean | undefined; + try { + await establishConversation(); + await act(async () => { + advertiseCapability('legacy-message', fake.socket.id); + }); + await act(async () => { + fake.simulateReconnect('socket-b'); + }); + await act(async () => { + refusedAfterReconnect = harnessSend()({ content: 'stale', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(refusedAfterReconnect).toBe(false); + expect(fake.emitted).toHaveLength(0); + expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE); + + // A fresh advertisement for the reconnected id (socket-b) re-arms sending. + const restore2 = installRandomUUID(() => UUID); + try { + await act(async () => { + advertiseCapability('legacy-message', 'socket-b'); + }); + await act(async () => { + harnessSend()({ content: 'welcome-back', selection: capSelection }); + }); + } finally { + restore2(); + } + + expect(fake.emitted).toContainEqual({ + event: 'message', + payload: { + conversationId: 'c1', + content: 'welcome-back', + provider: 'anthropic', + modelId: 'claude', + }, + }); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + }); + + it('routes on the synchronous protocol ref, not the batched reducer mirror: an advertisement and a send in the SAME tick still route by the just-advertised protocol', async () => { + // An advertisement lands and a send is issued within one synchronous tick, + // before React commits the reducer's `sendProtocol` mirror. The send is + // captured from the pre-advertisement render, so its closed-over reducer + // state still reads `sendProtocol === 'unavailable'`; the capability + // handler, however, has already set the synchronous `protocolRef` to + // `legacy-message`. The hook must route on that ref. Red (against a + // stale-mirror routing that reads `state.sendProtocol`): the send reads the + // pre-advertisement `unavailable` and refuses instead of emitting `message`. + const restore = installRandomUUID(() => UUID); + try { + await act(async () => { + // Bound to the CURRENT (pre-advertisement) render — its closure still + // sees the reset `unavailable` mirror even after the advert dispatches. + const sendBeforeCommit = harnessSend(); + advertiseCapability('legacy-message', fake.socket.id); + // Same tick, no await: React has not committed the new mirror yet, so + // only `protocolRef` reflects `legacy-message`. + sendBeforeCommit({ content: 'same-tick', selection: capSelection }); + }); + } finally { + restore(); + } + + expect(fake.emitted).toContainEqual({ + event: 'message', + payload: { + conversationId: undefined, + content: 'same-tick', + provider: 'anthropic', + modelId: 'claude', + }, + }); + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + }); + }); }); diff --git a/apps/web/src/spa/chat/use-chat-connection.ts b/apps/web/src/spa/chat/use-chat-connection.ts index b2a81dd2..b4850e1c 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, @@ -21,10 +22,14 @@ import type { AgentStartPayload, AgentTextPayload, AgentThinkingPayload, + ChatSendCapabilityPayload, + ChatSendProtocol, CommandDef, CommandManifest, CommandManifestPayload, ErrorPayload, + HarnessSelection, + HarnessTurnAckPayload, MessageAckPayload, SessionInfoPayload, SessionUsagePayload, @@ -130,6 +135,42 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr * dropped. */ const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.'; +/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn + * (`turn:ack` with `ok:false`). It is deliberately generic: the raw server + * `code`/`message`/`error` can carry adapter internals or entropy-source detail, + * so no rejection ever leaks its cause into the UI — every distinct rejection + * shows this same string. */ +const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.'; + +/** Fixed, browser-safe notice surfaced when a turn is refused because the + * idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing). + * Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */ +const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.'; + +/** The single fixed, browser-safe notice surfaced (with safe code + * `send_protocol_unavailable`) when a send is attempted on a connection whose + * negotiated send protocol is `unavailable` — the server never advertised a + * usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime + * in this slice), or the advertisement was rejected (wrong connection id, replay, + * or an unknown protocol). It carries no dynamic detail. */ +const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.'; + +/** Mints a single idempotency key for one accepted `turn:send`, fail-closed. + * Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that + * source is absent (not a function) or throws — the caller then refuses the turn + * rather than falling back to any non-cryptographic source (Math.random, a + * clock, or a counter would all be forgeable/collision-prone). Never throws. */ +function mintIdempotencyKey(): string | null { + try { + const c: unknown = globalThis.crypto; + if (!isRecord(c) || typeof c.randomUUID !== 'function') return null; + const key = (c.randomUUID as () => unknown)(); + return typeof key === 'string' && key.length > 0 ? key : null; + } catch { + return null; + } +} + /** True only for the narrow case a malformed-conversationId `error`/`agent:end` * must be treated as a terminal startup failure: no conversation has ever been * established yet, and a send is still pending one. Once a conversation is @@ -236,6 +277,14 @@ export interface PendingApproval { args?: string; } +/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the + * server accepted this exact turn under its minted idempotency key and selection tuple. */ +export interface HarnessTurnReceipt { + idempotencyKey: string; + receiptId: string; + selection: HarnessSelection; +} + export interface ChatConnectionState { conversationId: string | null; /** True once a message has been sent while no conversation is active yet, so the @@ -268,6 +317,18 @@ export interface ChatConnectionState { approvalRequestPending: boolean; systemReload: SystemReloadPayload | null; error: string | null; + /** How this connection is currently permitted to send, negotiated via the + * server-to-client-only `chat:send-capability` advertisement. Starts and resets + * to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped + * connection has no usable protocol until the server (re-)advertises. This is + * the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage` + * actually reads; the ref is authoritative because an advertisement and a send + * can occur in the same tick before React re-renders. */ + sendProtocol: ChatSendProtocol; + /** Receipt from the most recently accepted harness `turn:ack`, or null before any + * turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this + * untouched (a prior accepted receipt is not erased by a later rejection). */ + turnReceipt: HarnessTurnReceipt | null; messages: ChatTranscriptMessage[]; /** Monotonically increasing counter used to mint transcript message ids — * never reset while retained messages remain, so ids stay unique across the @@ -308,7 +369,7 @@ export interface ChatConnectionState { } export interface ChatConnectionActions { - sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void; + sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean; abort: () => void; setThinking: (level: string) => void; executeCommand: (input: { command: string; args?: string }) => void; @@ -341,6 +402,8 @@ const initialState: ChatConnectionState = { approvalRequestPending: false, systemReload: null, error: null, + sendProtocol: 'unavailable', + turnReceipt: null, messages: [], messageSeq: 0, toolSeq: 0, @@ -361,7 +424,12 @@ type Action = | { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload } | { type: 'server/system:reload'; payload: SystemReloadPayload } | { type: 'server/error'; payload: ErrorPayload } + | { type: 'server/turn:ack'; payload: HarnessTurnAckPayload } | { type: 'local/send'; content: string } + | { type: 'local/capability'; protocol: ChatSendProtocol } + | { type: 'local/reset-protocol' } + | { type: 'local/send-unavailable' } + | { type: 'local/turn-idempotency-unavailable' } | { type: 'local/approve-request'; command: string; args?: string } | { type: 'local/consume-approval' } | { type: 'local/approval-saturated' } @@ -778,6 +846,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState }; } + case 'server/turn:ack': { + // The harness runtime's turn acknowledgement. The success shape carries a + // receipt id + minted idempotencyKey + echoed selection; the failure shape + // is discriminated on `ok === false`. Every field is runtime-untrusted (the + // top-of-reducer guard already rejected a non-object payload). + const record = action.payload as Record; + if (record.ok === false) { + // A rejected turn surfaces a FIXED browser-safe notice — never the raw + // server `message`/`error`/`code`, which can carry adapter internals — and + // does not disturb any previously accepted receipt. + return { ...state, error: TURN_REJECTED_NOTICE }; + } + const idempotencyKey = asString(record.idempotencyKey); + // The web ack uses `receiptId`; fall back to the frozen contract's `turnId`. + const receiptId = asString(record.receiptId) || asString(record.turnId); + const selection = asHarnessSelection(record.selection); + if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) { + // A malformed success frame is ignored outright rather than recorded as a + // half-populated receipt. + return state; + } + return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } }; + } + case 'local/send': { const message: ChatTranscriptMessage = { // Sourced from the reducer-owned `messageSeq` counter — see the @@ -802,6 +894,39 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState }; } + case 'local/capability': { + // The FIRST valid `chat:send-capability` for this connection generation has + // been accepted (connection-id gating + first-wins enforced in the handler); + // record how this connection may now send. This is the reactive mirror of + // the synchronous `protocolRef` the send path reads. + return { ...state, sendProtocol: action.protocol }; + } + + case 'local/reset-protocol': { + // A (re)connect or disconnect voids any negotiated protocol: a fresh or + // dropped connection has no usable send capability until the server + // (re-)advertises. Reset to `unavailable` so no stale advertisement can + // authorize a send across a connection boundary. + if (state.sendProtocol === 'unavailable') return state; + return { ...state, sendProtocol: 'unavailable' }; + } + + case 'local/send-unavailable': { + // A send was attempted while the negotiated protocol is `unavailable` + // (never advertised / advertised unavailable / rejected advertisement). + // Surface the single FIXED safe notice — nothing was emitted, minted, + // appended, or locked. + return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE }; + } + + case 'local/turn-idempotency-unavailable': { + // The idempotency-key mint failed closed (crypto.randomUUID absent or + // throwing), so the turn was refused before emit. Surface a FIXED notice — + // never the underlying thrown message, which can leak entropy-source + // internals. + return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE }; + } + case 'local/disconnect': { // A transient socket disconnect must not leave the UI stuck waiting on // a turn/approval/send that will never resolve on this connection. @@ -882,6 +1007,20 @@ export function useChatConnection(): ChatConnectionValue { approveLockRef.current = state.approvalRequestPending; }, [state.approvalRequestPending]); + // Synchronous, generation-bound send protocol. `state.sendProtocol` drives the + // reactive UI, but reducer updates are batched/async — a `chat:send-capability` + // advertisement and a `sendMessage` can land in the same tick before React + // re-renders — so this ref is the source of truth the send path reads. Unlike + // sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is + // written directly by the socket lifecycle/capability handlers below, which + // also dispatch the reducer mirror. It is NOT synchronized from state, because + // its whole purpose is to be correct BEFORE the reducer has re-rendered. + const protocolRef = useRef('unavailable'); + // True once the first valid advertisement for the CURRENT connection generation + // has been accepted; every later advertisement (a conflicting or replayed one) + // is ignored until the next (re)connect/disconnect resets the generation. + const protocolLockedRef = useRef(false); + useEffect(() => { const socket = getSocket(); @@ -913,7 +1052,45 @@ export function useChatConnection(): ChatConnectionValue { const onError = (payload: ErrorPayload): void => { dispatch({ type: 'server/error', payload }); }; + const onTurnAck = (payload: HarnessTurnAckPayload): void => + dispatch({ type: 'server/turn:ack', payload }); + + // Void the negotiated send protocol at every connection-lifecycle boundary. + // A fresh or dropped connection has no usable capability until the server + // (re-)advertises, so no advertisement bound to a prior connection may carry + // across the boundary and authorize a send. Both write the synchronous ref + // AND unlock first-wins, then dispatch the reducer mirror. + const resetSendProtocol = (): void => { + protocolRef.current = 'unavailable'; + protocolLockedRef.current = false; + dispatch({ type: 'local/reset-protocol' }); + }; + const onConnect = (): void => { + resetSendProtocol(); + }; + const onCapability = (payload: ChatSendCapabilityPayload): void => { + // Server-to-client-only advertisement of how THIS connection may send. + // Accept only the FIRST valid one per generation, and only when it names + // this exact connection (`connectionId === socket.id`): a capability minted + // for another or stale connection must never arm this one. The payload is + // runtime-untrusted despite its compile-time type, so every field is + // guard-checked and an unknown protocol is dropped (leaving `unavailable`). + if (protocolLockedRef.current) return; + if (!isRecord(payload)) return; + const { protocol, connectionId } = payload as { + protocol?: unknown; + connectionId?: unknown; + }; + if (typeof connectionId !== 'string' || connectionId !== socket.id) return; + if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') { + return; + } + protocolLockedRef.current = true; + protocolRef.current = protocol; + dispatch({ type: 'local/capability', protocol }); + }; const onDisconnect = (): void => { + resetSendProtocol(); dispatch({ type: 'local/disconnect' }); }; @@ -930,6 +1107,11 @@ export function useChatConnection(): ChatConnectionValue { socket.on('command:approval', onCommandApproval); socket.on('system:reload', onSystemReload); socket.on('error', onError); + socket.on('turn:ack', onTurnAck); + // Registered BEFORE connect so the initial post-auth advertisement (and any + // reconnect) can never race ahead of its listener. + socket.on('connect', onConnect); + socket.on('chat:send-capability', onCapability); socket.on('disconnect', onDisconnect); if (!socket.connected) { @@ -950,24 +1132,79 @@ export function useChatConnection(): ChatConnectionValue { socket.off('command:approval', onCommandApproval); socket.off('system:reload', onSystemReload); socket.off('error', onError); + socket.off('turn:ack', onTurnAck); + socket.off('connect', onConnect); + socket.off('chat:send-capability', onCapability); socket.off('disconnect', onDisconnect); destroySocket(); }; }, []); const actions: ChatConnectionActions = { - sendMessage: ({ content, provider, modelId }) => { - if (sendLockRef.current || state.streaming || state.sending) return; - sendLockRef.current = true; - const socket = getSocket(); - if (!socket.connected) socket.connect(); - dispatch({ type: 'local/send', content }); - socket.emit('message', { - conversationId: state.conversationId ?? undefined, - content, - provider, - modelId, - }); + sendMessage: ({ content, selection }) => { + // Routing is PROTOCOL-driven, never inferred from conversation/selection/ + // provider/local mode: the server advertised, once per connection, exactly + // how this connection may send, and that advertisement is authoritative. + // The exhaustive switch maps each protocol to its ONE event; the send path + // never reconnects the socket (a dropped connection has already reset the + // protocol to `unavailable`, so no emit branch is reachable while offline). + switch (protocolRef.current) { + case 'legacy-message': { + // Embedded/legacy runtime: EVERY browser turn — the first (which + // creates the conversation) and every later one — is the `message` + // event. provider/model are sourced ONLY from the confirmed persisted + // selection tuple, never from separate flat caller inputs. + if (sendLockRef.current || state.streaming || state.sending) return false; + sendLockRef.current = true; + const socket = getSocket(); + dispatch({ type: 'local/send', content }); + socket.emit('message', { + conversationId: state.conversationId ?? undefined, + content, + provider: selection?.providerId, + modelId: selection?.modelId, + }); + return true; + } + case 'turn-send': { + // Pi turn-runtime: the exclusive `turn:send` contract. Requires an + // already-established conversation AND a confirmed persisted selection + // tuple; it is lock-independent (no send lock, no optimistic append), + // and mints exactly one idempotency key per accepted turn, failing the + // turn closed if the mint fails. A premature send (no conversation yet, + // or no selection) is refused with no emit and no notice. + if (selection == null || state.conversationId === null) return false; + const idempotencyKey = mintIdempotencyKey(); + if (idempotencyKey === null) { + dispatch({ type: 'local/turn-idempotency-unavailable' }); + return false; + } + const socket = getSocket(); + socket.emit('turn:send', { + conversationId: state.conversationId, + content, + selection, + idempotencyKey, + }); + return true; + } + case 'unavailable': { + // No usable protocol negotiated for this connection: refuse without + // emitting, minting, appending, or acquiring the lock, and surface the + // one fixed safe notice (code `send_protocol_unavailable`). + dispatch({ type: 'local/send-unavailable' }); + return false; + } + default: { + // Exhaustiveness guard: every ChatSendProtocol member is handled above. + // An unknown value can never arm a send — refuse exactly as + // `unavailable` rather than falling through to any emit. + const _exhaustive: never = protocolRef.current; + void _exhaustive; + dispatch({ type: 'local/send-unavailable' }); + return false; + } + } }, abort: () => { 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..1835290b 100644 --- a/apps/web/src/spa/pages/chat.spec.tsx +++ b/apps/web/src/spa/pages/chat.spec.tsx @@ -108,6 +108,58 @@ 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'); + } + }; +} + +/** + * Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only + * as the server advertised, once per connection, over the server-to-client-only + * `chat:send-capability`. Model that advertisement for THIS connection id so the + * page send tests take the intended branch. `legacy-message` is the default + * (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests + * reset the generation and re-advertise `turn-send` via the helper below. + */ +function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void { + fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id }); +} + +/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the + * default `legacy-message` advertisement + first-wins lock), then advertise the + * pi turn-runtime `turn:send` protocol for this connection. The per-test override + * for the page send tests that route through `turn:send`. */ +async function advertiseTurnSendGeneration(): Promise { + await act(async () => { + fake.simulateReconnect(); + }); + await act(async () => { + advertiseSendCapability('turn-send'); + }); +} + let fake: ReturnType; let root: Root | null; let container: HTMLElement; @@ -137,6 +189,12 @@ beforeEach(async () => { // Settle the selection hook's mount fetches so the default in-catalog tuple // persists and `canSend` is true for the existing send-path tests. await flushAsync(); + // Model the server's post-auth send-capability advertisement (MAJOR-1). Most + // page send tests exercise the legacy `message` branch; the pi turn-runtime + // tests override to `turn-send` via advertiseTurnSendGeneration(). + await act(async () => { + advertiseSendCapability('legacy-message'); + }); }); afterEach(async () => { @@ -159,6 +217,11 @@ async function remountWithFetch(fetchImpl: typeof fetch): Promise { root?.render(); }); await flushAsync(); + // Re-advertise on the remounted connection — the prior generation's capability + // does not carry across a remount (fresh hook instance, unadvertised protocol). + await act(async () => { + advertiseSendCapability('legacy-message'); + }); } describe('ChatPage', () => { @@ -571,6 +634,156 @@ describe('ChatPage', () => { expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } }); }); + it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => { + await advertiseTurnSendGeneration(); + const restore = installRandomUUID(() => PAGE_UUID); + try { + // Send is disabled without an active conversation — establish one first. + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + + const textarea = container.querySelector( + 'textarea[aria-label="Message"]', + ) as HTMLTextAreaElement; + await act(async () => { + setValue(textarea, 'hello there'); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); + } finally { + restore(); + } + + const sends = fake.emitted.filter((e) => e.event === 'turn:send'); + expect(sends).toHaveLength(1); + expect(sends[0]?.payload).toEqual({ + conversationId: 'c1', + content: 'hello there', + selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }, + idempotencyKey: PAGE_UUID, + }); + // The pi-rpc page send must not emit the embedded `message` event, and must + // never send a flat {provider, modelId} that drops the harnessId. + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + }); + + it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => { + await advertiseTurnSendGeneration(); + const failing = installRandomUUID(() => { + throw new Error('secure random unavailable'); + }); + try { + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + const textarea = container.querySelector( + 'textarea[aria-label="Message"]', + ) as HTMLTextAreaElement; + await act(async () => { + setValue(textarea, 'keep me'); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); + + // No wire traffic: neither the harness turn nor the legacy message. + expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + // The composer retained its content — it clears ONLY on a successful send, + // so the user can retry without retyping. + expect(textarea.value).toBe('keep me'); + // A visible, safe notice explains why nothing was sent. + expect(container.querySelector('[role="alert"]')).toBeTruthy(); + } finally { + failing(); + } + }); + + it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => { + await advertiseTurnSendGeneration(); + const restore = installRandomUUID(() => PAGE_UUID); + try { + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + const textarea = container.querySelector( + 'textarea[aria-label="Message"]', + ) as HTMLTextAreaElement; + await act(async () => { + setValue(textarea, 'ship it'); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); + + const sends = fake.emitted.filter((e) => e.event === 'turn:send'); + expect(sends).toHaveLength(1); + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + // On a successful send the composer clears. + expect(textarea.value).toBe(''); + } finally { + restore(); + } + }); + + it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => { + await advertiseTurnSendGeneration(); + const restore = installRandomUUID(() => PAGE_UUID); + try { + // Change the selection away from the mount default and let it persist. + const providerSelect = container.querySelector( + 'select[aria-label="Provider"]', + ) as HTMLSelectElement; + await act(async () => { + selectValue(providerSelect, 'anthropic'); + }); + const modelSelect = container.querySelector( + 'select[aria-label="Model"]', + ) as HTMLSelectElement; + await act(async () => { + selectValue(modelSelect, 'anthropic:claude'); + }); + await flushAsync(); + + await act(async () => { + fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); + }); + const textarea = container.querySelector( + 'textarea[aria-label="Message"]', + ) as HTMLTextAreaElement; + await act(async () => { + setValue(textarea, 'routed'); + }); + await act(async () => { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); + + const sends = fake.emitted.filter((e) => e.event === 'turn:send'); + expect(sends).toHaveLength(1); + // The nested tuple reflects the CURRENTLY persisted selection, not the + // mount default {openai, gpt-5}, and never flat provider/model fields. + expect(sends[0]?.payload).toEqual({ + conversationId: 'c1', + content: 'routed', + selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' }, + idempotencyKey: PAGE_UUID, + }); + expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); + } finally { + restore(); + } + }); + it('disables send until a selection has persisted — no send with an unset selection', async () => { await remountWithFetch(harnessFetch(null)); diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 075431af..51852053 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,8 +108,70 @@ export interface AbortPayload { conversationId: string; } +/** + * The frozen P3 `turn:send` wire contract (Task Five; reused unchanged by Tasks 15 and 16). + * Accepts no attachments or authority-bearing fields in Slice Zero. Gateway validation + * requires a UUID conversation id, non-empty bounded content, a nested selection with exactly + * `harnessId`/`providerId`/`modelId` (each 1..255 chars), and a UUID-v4 idempotency key; it + * rejects unknown fields, top-level `provider`/`modelId`, malformed nesting, and empty values + * before any runtime dispatch. + */ +export interface HarnessTurnSendPayload { + readonly conversationId: string; // UUID; required before send + readonly content: string; // trimmed, 1..10_000 characters + readonly selection: HarnessSelection; // nested; exactly three ids + readonly idempotencyKey: string; // browser-generated UUID v4 +} + +/** + * The frozen `turn:ack` wire contract. Success echoes the accepted idempotency key and the + * exact requested selection tuple; failure carries only fixed/safe text and never a + * substituted effective selection or raw exception text. + */ +export type HarnessTurnAckPayload = + | { + readonly ok: true; + readonly conversationId: string; + readonly idempotencyKey: string; + readonly turnId: string; + readonly correlationId: string; + readonly state: HarnessTurnState; + readonly selection: HarnessSelection; + } + | { + readonly ok: false; + readonly conversationId?: string; + readonly idempotencyKey?: string; + readonly code: HarnessErrorCode | 'request_invalid' | 'runtime_unsupported'; + readonly message: string; // fixed/safe text only + readonly retryable: boolean; + readonly correlationId: string; + /** Present only when a complete tuple was validated; always the requested tuple. */ + readonly selection?: HarnessSelection; + }; + +/** + * The frozen browser send-protocol advertisement (Task Five; server → client only). + * + * A conversation id or a harness selection never proves that the connected Gateway actually + * handles a given wire event, so after BetterAuth authenticates a browser Socket connection the + * Gateway advertises — exactly once, targeted to that socket — which send event the client may + * use. `legacy-message` in legacy mode, `unavailable` in `pi-rpc` (including test-ready Pi + * graphs); Task Five never advertises `turn-send` (its authenticated handler lands in Task 15). + * Capability is routing information, never authorization: every server handler still enforces + * authentication, ownership, DTO, mode, and runtime checks. + */ +export type ChatSendProtocol = 'legacy-message' | 'turn-send' | 'unavailable'; + +export interface ChatSendCapabilityPayload { + readonly protocol: ChatSendProtocol; + /** Exact Socket.IO id for the authenticated browser connection this advertisement is bound to. */ + readonly connectionId: string; +} + /** Socket.IO typed event map: server → client */ export interface ServerToClientEvents { + 'chat:send-capability': (payload: ChatSendCapabilityPayload) => void; 'message:ack': (payload: MessageAckPayload) => void; 'agent:start': (payload: AgentStartPayload) => void; 'agent:end': (payload: AgentEndPayload) => void; @@ -121,12 +184,14 @@ export interface ServerToClientEvents { 'command:result': (payload: SlashCommandResultPayload) => void; 'command:approval': (payload: SlashCommandApprovalResultPayload) => void; 'system:reload': (payload: SystemReloadPayload) => void; + 'turn:ack': (payload: HarnessTurnAckPayload) => void; error: (payload: ErrorPayload) => void; } /** Socket.IO typed event map: client → server */ export interface ClientToServerEvents { message: (data: ChatMessagePayload) => void; + 'turn:send': (data: HarnessTurnSendPayload) => void; 'set:thinking': (data: SetThinkingPayload) => void; 'command:execute': (data: SlashCommandPayload) => void; 'command:approve': (data: SlashCommandPayload) => void; diff --git a/packages/types/src/chat/index.ts b/packages/types/src/chat/index.ts index feaa002b..036eae84 100644 --- a/packages/types/src/chat/index.ts +++ b/packages/types/src/chat/index.ts @@ -14,6 +14,10 @@ export type { AbortPayload, ErrorPayload, ChatMessagePayload, + HarnessTurnSendPayload, + HarnessTurnAckPayload, + ChatSendProtocol, + ChatSendCapabilityPayload, ServerToClientEvents, ClientToServerEvents, } from './events.js';