Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15561263cc | ||
|
|
44b244f5c0 | ||
|
|
f2661d2c6e | ||
|
|
95d48b02cb | ||
|
|
d6fa67982e | ||
|
|
1237216e63 | ||
|
|
49a8ff73fd | ||
|
|
32be7e547a | ||
|
|
9152bb2b14 | ||
|
|
9c7fb4eda6 | ||
|
|
216cd72226 | ||
|
|
6a8ce66702 | ||
|
|
9cd9409089 | ||
|
|
13c70a7a10 | ||
|
|
dd6357e670 | ||
|
|
709a23d08c | ||
|
|
239a2a93f1 | ||
|
|
ea1f058022 | ||
|
|
1fde450ff1 | ||
|
|
c136baa052 | ||
|
|
4f7f6b3281 | ||
|
|
77edb0dea2 | ||
|
|
3676180ae8 | ||
|
|
0e938b66ed | ||
|
|
f0fef26eb7 | ||
|
|
c9bccd4aae | ||
|
|
8ef2e5b91d | ||
|
|
4cab6c09fe | ||
|
|
239fc6d03c | ||
|
|
d085182dc1 | ||
|
|
e949fa3767 | ||
|
|
f1761c91be | ||
|
|
8109f72cf7 | ||
|
|
a0be592d84 | ||
|
|
f4a24b693e | ||
|
|
e4dffb7c18 | ||
|
|
f840843908 | ||
|
|
aacb11b0b9 | ||
|
|
ce6bda18f2 | ||
|
|
aca28405be | ||
|
|
c1eb0659c4 | ||
|
|
b79708fdc7 | ||
|
|
ebe415132e | ||
|
|
f16f206a0a | ||
|
|
a186922e3a | ||
|
|
43513c28f7 | ||
|
|
b6c12bdfcb | ||
|
|
fb9f9cda5a | ||
|
|
400a21ca18 | ||
|
|
4cefa5cd88 | ||
|
|
ddf8616716 | ||
|
|
30a694358d | ||
|
|
e01dfa0cd7 | ||
|
|
6c4a2eb626 | ||
|
|
9185b0cce4 | ||
|
|
8925a502ae | ||
|
|
0e4eb1445c | ||
|
|
592d60425f | ||
|
|
a43f343efd | ||
|
|
88ef9d4fa5 | ||
|
|
bda308efd9 | ||
|
|
20718b5a27 | ||
|
|
29db24210c | ||
|
|
a6085eea37 | ||
|
|
063de8cd85 | ||
|
|
11ffe65c97 | ||
|
|
dcaf01c789 | ||
|
|
7ddd2f5e1d | ||
|
|
16920c4a6f | ||
|
|
6b3ebce343 | ||
|
|
7fa0f65a60 | ||
|
|
f4faa3f819 | ||
|
|
0692d999f6 | ||
|
|
fa35c6abed | ||
|
|
53d4ea6ec6 | ||
|
|
39987a5b61 | ||
|
|
00bdf8b28c | ||
|
|
631567d5f7 | ||
|
|
9f741874bd | ||
|
|
430b4d5f5d | ||
|
|
404db8cd70 | ||
|
|
c0262e8856 | ||
|
|
e00cc475a2 | ||
|
|
306985990c | ||
|
|
2082ac061b | ||
|
|
18ee6eb33d | ||
|
|
76f1f1c8d3 | ||
|
|
0da1deb83f | ||
|
|
4aa67e8dff | ||
|
|
7425edb80f | ||
|
|
571a3d54b5 | ||
|
|
bcd174f89e | ||
|
|
94fc3e55f5 | ||
|
|
46d29d82e9 | ||
|
|
d210c2d7ea | ||
|
|
bf32f29acd | ||
|
|
48531755eb | ||
|
|
9a1cc63383 | ||
|
|
0830e2e3ae | ||
|
|
205cc0d7a1 | ||
|
|
698655d40a | ||
|
|
dcad7de033 | ||
|
|
cd4409abc3 | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
a45f53071a | ||
|
|
00eb216480 | ||
|
|
8c27024d0e | ||
|
|
406e40584d | ||
|
|
677aeb0c93 | ||
|
|
bd0ef2ab25 | ||
|
|
2d5a8c81ec | ||
|
|
884d527cc8 | ||
|
|
b82a51da80 | ||
|
|
b4753a75cd | ||
|
|
dc67590a96 | ||
|
|
baf4306f51 | ||
|
|
12677a928d |
+2
-3
@@ -149,6 +149,5 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
# The web login page discovers configured providers dynamically from
|
||||
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ coverage
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
docs/.obsidian
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
|
||||
@@ -109,6 +109,16 @@ steps:
|
||||
# `apk add` guarantees openssl is present on PR pipelines too (and is a
|
||||
# fast no-op once the rebuilt image already ships it).
|
||||
- apk add --no-cache openssl
|
||||
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
|
||||
# installed `pi` binary at exactly this measured version — the test
|
||||
# boots Pi's real tool registry to prove the read-only carve-out
|
||||
# resolves to real, unshadowed builtins, and fails loud (by design)
|
||||
# when the runtime is absent or drifts. The canonical Pi is
|
||||
# @earendil-works/[email protected] exactly (@mariozechner/* is
|
||||
# embedded-legacy). Step-level install because ci-base image publishes
|
||||
# are currently blocked on registry auth; fold into Dockerfile.ci once
|
||||
# that is fixed, keeping this as a fast no-op guard.
|
||||
- npm install -g @earendil-works/[email protected]
|
||||
# postgresql-client (pg_isready) is baked into ci-base.
|
||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||
- |
|
||||
|
||||
@@ -11,48 +11,87 @@
|
||||
|
||||
## Project Context
|
||||
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo with NestJS gateway, Next.js web dashboard, Pi SDK agent runtime, and plugin architecture for Discord/Telegram.
|
||||
Mosaic Stack is a self-hosted, multi-user AI agent platform. It is a TypeScript monorepo with a NestJS gateway, Next.js dashboard, Pi SDK agent runtime, and Discord/Telegram plugin architecture.
|
||||
|
||||
## Package Map
|
||||
### Stack
|
||||
|
||||
- **API:** NestJS with Fastify (`apps/gateway`)
|
||||
- **Web:** Next.js 16 with React 19 (`apps/web`)
|
||||
- **ORM and database:** Drizzle ORM, PostgreSQL 17, and pgvector (`packages/db`)
|
||||
- **Authentication:** BetterAuth (`packages/auth`)
|
||||
- **Agent runtime:** Pi SDK (`apps/gateway`, `packages/mosaic`)
|
||||
- **Queue:** Valkey 8 (`packages/queue`)
|
||||
- **Build:** pnpm workspaces and Turborepo
|
||||
- **CI:** Woodpecker CI
|
||||
- **Observability:** OpenTelemetry and Jaeger
|
||||
|
||||
### Package Map
|
||||
|
||||
| Package | Purpose | Key Dependencies |
|
||||
| ------------------ | ------------------------------- | -------------------------------- |
|
||||
| ------------------ | ----------------------------- | -------------------------------- |
|
||||
| `apps/gateway` | NestJS API + WebSocket hub | Fastify, Socket.IO, Pi SDK, OTEL |
|
||||
| `apps/web` | Next.js dashboard | React 19, Tailwind |
|
||||
| `packages/types` | Shared TypeScript contracts | class-validator |
|
||||
| `packages/db` | Drizzle ORM schema + migrations | drizzle-orm, postgres |
|
||||
| `packages/db` | Drizzle schema and migrations | drizzle-orm, postgres |
|
||||
| `packages/auth` | BetterAuth configuration | better-auth, @mosaicstack/db |
|
||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||
| `packages/brain` | Structured data layer | @mosaicstack/db |
|
||||
| `packages/queue` | Valkey task queue and MCP | ioredis |
|
||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||
| `packages/mosaic` | Unified `mosaic` CLI and TUI | Ink, Pi SDK, commander |
|
||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||
|
||||
## Architecture Rules
|
||||
## Architecture and Code Conventions
|
||||
|
||||
1. Gateway is the single API surface — all clients connect through it
|
||||
2. Pi SDK is ESM-only — gateway and CLI must use ESM
|
||||
3. Socket.IO typed events defined in `@mosaicstack/types` enforce compile-time contracts
|
||||
4. OTEL auto-instrumentation loads before NestJS bootstrap
|
||||
5. BetterAuth manages auth tables; schema defined in `@mosaicstack/db`
|
||||
6. Docker Compose provides PG (5433), Valkey (6380), OTEL Collector (4317/4318), Jaeger (16686)
|
||||
7. Explicit `@Inject()` decorators required in NestJS (tsx/esbuild doesn't emit decorator metadata)
|
||||
1. Gateway is the single API surface; all clients connect through it.
|
||||
2. Pi SDK is ESM-only; gateway and CLI code must remain ESM.
|
||||
3. Use `"type": "module"`, NodeNext module resolution, and `.js` extensions in imports.
|
||||
4. Keep typed Socket.IO events in `@mosaicstack/types` to enforce client/server contracts.
|
||||
5. Import OTEL tracing before NestJS bootstrap (`import './tracing.js'`).
|
||||
6. Use explicit `@Inject()` decorators in NestJS because tsx/esbuild does not emit decorator metadata.
|
||||
7. Keep DTOs in `*.dto.ts` files at module boundaries.
|
||||
8. BetterAuth owns authentication tables; their schema is defined in `@mosaicstack/db`.
|
||||
9. Create a task-specific scratchpad for non-trivial work.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Requirements: Node.js 20+, pnpm 10.6.2, and Docker Compose when optional local services are needed.
|
||||
|
||||
```bash
|
||||
docker compose up -d # Infrastructure
|
||||
pnpm install # Dependencies
|
||||
pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only; do not start the full Compose stack.
|
||||
docker compose up -d valkey
|
||||
```
|
||||
|
||||
## Repo-Specific Notes
|
||||
The pre-push hook requires:
|
||||
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
```bash
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
```
|
||||
|
||||
Software delivery also requires the applicable tests. Common repository commands are:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript checks across the workspace
|
||||
pnpm lint # ESLint across the workspace
|
||||
pnpm test # Checkout tests and package Vitest suites
|
||||
pnpm format:check # Prettier check
|
||||
pnpm build # Build all packages and applications
|
||||
```
|
||||
|
||||
## Database and Local Runtime Safety
|
||||
|
||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||
- PostgreSQL execution is held until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
- Do not invoke a migration runner, initialization SQL, or the Compose PostgreSQL service from this checkout.
|
||||
- Do not start Gateway/Web or run root `pnpm dev` as a local PGlite route. The current dotenv loader can inherit a daemon PostgreSQL DSN; KBN-101-02 must make that path fail closed first.
|
||||
- Migration artifact generation is offline and does not authorize PostgreSQL access:
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate
|
||||
```
|
||||
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
|
||||
@@ -1,46 +1,5 @@
|
||||
# CLAUDE.md — Mosaic Stack
|
||||
# Claude Compatibility Pointer
|
||||
|
||||
## Project
|
||||
@AGENTS.md
|
||||
|
||||
Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
||||
|
||||
## Stack
|
||||
|
||||
- **API**: NestJS + Fastify adapter (`apps/gateway`)
|
||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||
- **Auth**: BetterAuth (`packages/auth`)
|
||||
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||
- **Queue**: Valkey 8 (`packages/queue`)
|
||||
- **Build**: pnpm workspaces + Turborepo
|
||||
- **CI**: Woodpecker CI
|
||||
- **Observability**: OpenTelemetry → Jaeger
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript check (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm format:check # Prettier check
|
||||
pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||
|
||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||
docker compose up -d valkey
|
||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution
|
||||
- Explicit `@Inject()` decorators in NestJS (tsx/esbuild doesn't support emitDecoratorMetadata)
|
||||
- DTOs in `*.dto.ts` files at module boundaries
|
||||
- OTEL tracing imported before NestJS bootstrap (`import './tracing.js'`)
|
||||
- All three gates must pass before push: typecheck, lint, format:check
|
||||
Do not add project guidance here. Keep `AGENTS.md` authoritative so every agent runtime receives the same instructions.
|
||||
|
||||
@@ -48,9 +48,13 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js ≥ 20
|
||||
- Node.js ≥ 22
|
||||
- npm (for global @mosaicstack/mosaic install)
|
||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||
- One or more runtimes:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [OpenCode](https://opencode.ai)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -200,7 +204,7 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js ≥ 20
|
||||
- Node.js ≥ 22
|
||||
- pnpm 10.6+
|
||||
- Docker & Docker Compose
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
|
||||
},
|
||||
];
|
||||
brain = createMockBrain({ searchResults });
|
||||
controller = new ConversationsController(brain as never);
|
||||
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
});
|
||||
|
||||
it('returns matching messages for a valid search query', async () => {
|
||||
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
|
||||
describe('ConversationsController — message CRUD', () => {
|
||||
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
||||
const brain = createMockBrain({ conversation: undefined });
|
||||
const controller = new ConversationsController(brain as never);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
|
||||
it('listMessages returns the messages for an owned conversation', async () => {
|
||||
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
||||
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
||||
const controller = new ConversationsController(brain as never);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
||||
|
||||
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
|
||||
|
||||
it('addMessage returns the persisted message', async () => {
|
||||
const brain = createMockBrain({ conversation: makeConversation() });
|
||||
const controller = new ConversationsController(brain as never);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
const result = await controller.addMessage(
|
||||
CONV_ID,
|
||||
|
||||
@@ -35,6 +35,25 @@ function payload(content: string, messageId: string, correlationId: string): Dis
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
|
||||
* those paths run entirely through the command-authorization, runtime-provider and durable-session
|
||||
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
|
||||
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
|
||||
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
|
||||
* stays a GREEN cross-surface control.
|
||||
*/
|
||||
function failIfUsedChatRuntimeRouter() {
|
||||
return {
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the Discord control path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
return new CommandAuthorizationService(
|
||||
@@ -113,7 +132,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
|
||||
},
|
||||
);
|
||||
const gateway = new ChatGateway(
|
||||
{} as never,
|
||||
failIfUsedChatRuntimeRouter() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
|
||||
// The repo enforces ownership via the WHERE clause; it returns undefined when the
|
||||
// conversation does not belong to the requesting user.
|
||||
brain.conversations.findById.mockResolvedValue(undefined);
|
||||
const controller = new ConversationsController(brain as never);
|
||||
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
|
||||
|
||||
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'reflect-metadata';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||
@@ -12,10 +14,25 @@ vi.mock('../routing/routing-engine.service.js', () => ({
|
||||
}));
|
||||
|
||||
import { SessionsController } from '../sessions.controller.js';
|
||||
import { AgentService } from '../agent.service.js';
|
||||
import { ChatController } from '../../chat/chat.controller.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import type { AgentSession } from '../agent.service.js';
|
||||
import type { SessionInfoDto } from '../session.dto.js';
|
||||
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||
import { AuthGuard } from '../../auth/auth.guard.js';
|
||||
import { AUTH } from '../../auth/auth.tokens.js';
|
||||
import { BRAIN } from '../../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../../commands/command-executor.service.js';
|
||||
import { RoutingEngineService } from '../routing/routing-engine.service.js';
|
||||
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
|
||||
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
|
||||
import { ownConversation } from '../../chat/chat-runtime.js';
|
||||
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
|
||||
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
|
||||
import { HarnessRegistry } from '../../harness/harness.registry.js';
|
||||
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
|
||||
|
||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||
@@ -74,6 +91,12 @@ function makeAgentSession(owner = USER_A): AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
|
||||
* USER_A's conversation id) is never granted the session. Because every method exists and no method
|
||||
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
|
||||
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
|
||||
*/
|
||||
function makeScopedAgentService() {
|
||||
const foreign = makeAgentSession(USER_A);
|
||||
return {
|
||||
@@ -87,7 +110,7 @@ function makeScopedAgentService() {
|
||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? undefined : foreign,
|
||||
),
|
||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
||||
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
@@ -96,6 +119,201 @@ function makeScopedAgentService() {
|
||||
};
|
||||
}
|
||||
|
||||
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
|
||||
|
||||
/**
|
||||
* A structurally-complete harness conversation service that throws if any method is invoked.
|
||||
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
|
||||
*/
|
||||
const failIfUsedConversationService = {
|
||||
attach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
detach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
send: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
|
||||
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
|
||||
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
|
||||
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
|
||||
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
|
||||
*/
|
||||
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(failIfUsedConversationService);
|
||||
const router = new ChatRuntimeRouter(
|
||||
new HarnessRegistry(),
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
embedded,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
|
||||
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
|
||||
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
|
||||
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
|
||||
*/
|
||||
const FORBIDDEN_AGENT_OPS = [
|
||||
'getSession',
|
||||
'createSession',
|
||||
'onEvent',
|
||||
'addChannel',
|
||||
'prompt',
|
||||
'setThinking',
|
||||
'abort',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
|
||||
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
|
||||
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
|
||||
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
|
||||
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
|
||||
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
|
||||
*
|
||||
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
|
||||
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
|
||||
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
|
||||
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
|
||||
*/
|
||||
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
|
||||
return new Proxy(target, {
|
||||
get(t, prop) {
|
||||
const value = Reflect.get(t, prop);
|
||||
if (typeof value === 'function' && typeof prop === 'string') {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push(prop);
|
||||
return (value as (...a: unknown[]) => unknown).apply(t, args);
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as ChatRuntimeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
|
||||
*
|
||||
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
|
||||
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
|
||||
* resolves whichever its constructor declares:
|
||||
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
|
||||
* router (and its embedded fake) is never reached.
|
||||
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
|
||||
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
|
||||
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
|
||||
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
|
||||
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
|
||||
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
|
||||
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
|
||||
*/
|
||||
function buildRestModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
return (
|
||||
Test.createTestingModule({
|
||||
controllers: [ChatController],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
})
|
||||
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
|
||||
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
|
||||
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile()
|
||||
);
|
||||
}
|
||||
|
||||
function buildGatewayModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
const brain = {
|
||||
conversations: {
|
||||
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
|
||||
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
|
||||
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
|
||||
// property has its own dedicated coverage.
|
||||
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
|
||||
},
|
||||
};
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
ChatGateway,
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
|
||||
{ provide: BRAIN, useValue: brain },
|
||||
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
|
||||
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
|
||||
{
|
||||
provide: RoutingEngineService,
|
||||
useValue: {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||
@@ -152,50 +370,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new ChatController(agentService as never);
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
|
||||
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
|
||||
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
|
||||
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
|
||||
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
|
||||
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
|
||||
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
|
||||
try {
|
||||
const controller = moduleRef.get(ChatController, { strict: false });
|
||||
|
||||
// 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.toMatchObject({ status: 404 });
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
// 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,
|
||||
});
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
// 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 (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();
|
||||
// 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, {
|
||||
await Promise.resolve(
|
||||
gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
});
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
// 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,
|
||||
});
|
||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||
// 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,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
||||
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);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
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;
|
||||
|
||||
const first = await lease.dispatch();
|
||||
expect(first).toEqual({ ok: true, value: undefined });
|
||||
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||
|
||||
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);
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
|
||||
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||
expect(prepared.ok).toBe(false);
|
||||
// Exactly what was acquired (the event listener) is rolled back.
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
|
||||
const { svc, detach } = makeRejectingPromptAgentService();
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
|
||||
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, {
|
||||
content: 'trigger a backend failure',
|
||||
});
|
||||
|
||||
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
|
||||
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
|
||||
// The single idempotent dispose ran in the catch: listener detached exactly once.
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
|
||||
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
|
||||
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
|
||||
vi.advanceTimersByTime(600_000);
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
expect(unhandled).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const detach = vi.fn();
|
||||
const svc = {
|
||||
getSession: vi.fn(() => session),
|
||||
createSession: vi.fn(),
|
||||
onEvent: vi.fn(() => detach),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
|
||||
// `await prompt()` the timer could never even be observed, so the turn hung forever.
|
||||
prompt: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const detach = vi.fn();
|
||||
let rejectPrompt: (reason: unknown) => void = () => undefined;
|
||||
const prompting = new Promise<void>((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||
import type { RoutingRule } from './routing.types.js';
|
||||
@@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js';
|
||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||
function makeService(
|
||||
rules: RoutingRule[],
|
||||
healthMap: Record<string, { status: string }>,
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] {
|
||||
}
|
||||
|
||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||
const allHealthy: Record<string, { status: string }> = {
|
||||
anthropic: { status: 'up' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
ollama: { status: 'up' },
|
||||
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
ollama: { status: 'healthy' },
|
||||
};
|
||||
|
||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||
@@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||
const message = 'implement a sort function';
|
||||
|
||||
const unhealthyHealth = {
|
||||
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'down' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
ollama: { status: 'down' },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
import { classifyTask } from './task-classifier.js';
|
||||
@@ -49,7 +50,7 @@ export class RoutingEngineService {
|
||||
async resolve(
|
||||
message: string,
|
||||
userId?: string,
|
||||
availableProviders?: Record<string, { status: string }>,
|
||||
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
||||
): Promise<RoutingDecision> {
|
||||
const classification = classifyTask(message);
|
||||
this.logger.debug(
|
||||
@@ -69,9 +70,8 @@ export class RoutingEngineService {
|
||||
if (!this.matchConditions(rule, classification)) continue;
|
||||
|
||||
const providerStatus = health[rule.action.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
|
||||
if (!isHealthy) {
|
||||
if (!this.isRoutable(providerStatus)) {
|
||||
this.logger.debug(
|
||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||
);
|
||||
@@ -111,6 +111,10 @@ export class RoutingEngineService {
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
||||
return status === 'healthy' || status === 'degraded';
|
||||
}
|
||||
|
||||
private evaluateCondition(
|
||||
condition: RoutingCondition,
|
||||
classification: TaskClassification,
|
||||
@@ -186,11 +190,12 @@ export class RoutingEngineService {
|
||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||
* If none are healthy, return the first entry unconditionally (last resort).
|
||||
*/
|
||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
||||
private applyFallbackChain(
|
||||
health: Record<string, { status: ProviderHealthStatus }>,
|
||||
): RoutingDecision {
|
||||
for (const candidate of FALLBACK_CHAIN) {
|
||||
const providerStatus = health[candidate.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
if (isHealthy) {
|
||||
if (this.isRoutable(providerStatus)) {
|
||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||
return {
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||
|
||||
@@ -29,7 +30,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||
function makeService(
|
||||
rules: RoutingRule[] = [],
|
||||
healthMap: Record<string, { status: string }> = {},
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('high priority');
|
||||
@@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('coding rule');
|
||||
@@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // primary is unhealthy
|
||||
openai: { status: 'up' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
@@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
];
|
||||
|
||||
const service2 = makeService(unhealthyRules, {
|
||||
anthropic: { status: 'up' },
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'down' },
|
||||
});
|
||||
|
||||
@@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
||||
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
||||
});
|
||||
|
||||
const decision = await service.resolve('hello there');
|
||||
@@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const decision = await service.resolve('completely unrelated message xyz');
|
||||
expect(decision.ruleName).toBe('catch-all');
|
||||
@@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const codingDecision = await service.resolve('implement a function');
|
||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||
@@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('enabled fallback');
|
||||
@@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
||||
ps: unknown,
|
||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||
|
||||
const preSupplied = { anthropic: { status: 'up' } };
|
||||
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
};
|
||||
await service.resolve('implement a function', undefined, preSupplied);
|
||||
|
||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
||||
|
||||
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
||||
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
||||
const codingRule = makeRule({
|
||||
name: 'coding rule',
|
||||
priority: 1,
|
||||
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
||||
action: { provider: 'openai', model: 'gpt-4o' },
|
||||
});
|
||||
|
||||
// healthy → selected by its own rule, not the fallback chain
|
||||
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
||||
const healthyDecision = await healthyService.resolve('implement a function');
|
||||
expect(healthyDecision.ruleName).toBe('coding rule');
|
||||
expect(healthyDecision.provider).toBe('openai');
|
||||
|
||||
// down → rule is skipped as unroutable, falls through to the fallback chain
|
||||
const downService = makeService([codingRule], {
|
||||
openai: { status: 'down' },
|
||||
anthropic: { status: 'healthy' },
|
||||
});
|
||||
const downDecision = await downService.resolve('implement a function');
|
||||
expect(downDecision.ruleName).toBe('fallback');
|
||||
expect(downDecision.provider).toBe('anthropic');
|
||||
|
||||
// degraded → still routable, selected by its own rule, not the fallback chain
|
||||
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
||||
const degradedDecision = await degradedService.resolve('implement a function');
|
||||
expect(degradedDecision.ruleName).toBe('coding rule');
|
||||
expect(degradedDecision.provider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
interface ComposedModuleGraph {
|
||||
imports: readonly unknown[];
|
||||
federationModule: unknown;
|
||||
bootLogLines: readonly string[];
|
||||
mosaicConfig: MosaicConfig;
|
||||
resolvedConfigPath: string;
|
||||
}
|
||||
|
||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
||||
|
||||
interface ModuleGraphFixture {
|
||||
tempRoot: string;
|
||||
anchor: string;
|
||||
homePath: string;
|
||||
cwdPath: string;
|
||||
monorepoRootEnvPath: string;
|
||||
gatewayLocalEnvPath: string;
|
||||
daemonEnvPath: string;
|
||||
monorepoRootConfigPath: string;
|
||||
gatewayLocalConfigPath: string;
|
||||
}
|
||||
|
||||
interface ModuleGraphFixtureOptions {
|
||||
rootEnvMode?: 'present' | 'absent';
|
||||
rootTier?: StorageTier;
|
||||
rootEnvContents?: string;
|
||||
redactionMarker?: string;
|
||||
gatewayLocalTier?: StorageTier;
|
||||
gatewayLocalEnvContents?: string;
|
||||
daemonEnvContents?: string;
|
||||
inheritedTier?: StorageTier;
|
||||
expectedProcessTier?: string;
|
||||
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
||||
}
|
||||
|
||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
||||
|
||||
function configJson(tier: StorageTier): string {
|
||||
if (tier === 'local') {
|
||||
return JSON.stringify({
|
||||
tier,
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
tier,
|
||||
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||
const relativePath = relative(tempRoot, path);
|
||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface ConfigModuleProvider {
|
||||
provide: string;
|
||||
useFactory: () => MosaicConfig;
|
||||
}
|
||||
|
||||
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!('provide' in value) || typeof value.provide !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'useFactory' in value && typeof value.useFactory === 'function';
|
||||
}
|
||||
|
||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
||||
expect(bootLogLines).toHaveLength(1);
|
||||
const [bootLogLine] = bootLogLines;
|
||||
if (bootLogLine === undefined) {
|
||||
throw new Error('Expected a single boot log line');
|
||||
}
|
||||
|
||||
return bootLogLine;
|
||||
}
|
||||
|
||||
function expectBootLogLine(
|
||||
bootLogLines: readonly string[],
|
||||
tier: StorageTier,
|
||||
source: string,
|
||||
): void {
|
||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
||||
|
||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
||||
expect(bootLogLine).toContain(`source=${source}`);
|
||||
}
|
||||
|
||||
async function loadModuleGraphFromDotenv(
|
||||
options: ModuleGraphFixtureOptions,
|
||||
): Promise<ComposedModuleGraph> {
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
|
||||
try {
|
||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||
const homePath = join(tempRoot, 'home');
|
||||
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
||||
const fixture: ModuleGraphFixture = {
|
||||
tempRoot,
|
||||
anchor,
|
||||
homePath,
|
||||
cwdPath,
|
||||
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
||||
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
||||
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
||||
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
||||
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
||||
};
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
|
||||
for (const path of Object.values(fixture)) {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
}
|
||||
|
||||
await mkdir(anchor, { recursive: true });
|
||||
await mkdir(cwdPath, { recursive: true });
|
||||
|
||||
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
||||
if (
|
||||
options.rootEnvContents !== undefined ||
|
||||
options.rootTier !== undefined ||
|
||||
options.redactionMarker !== undefined
|
||||
) {
|
||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
||||
}
|
||||
} else {
|
||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
||||
throw new Error('Expected rootTier or rootEnvContents');
|
||||
}
|
||||
|
||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
||||
const rootFixtureWithMarker = options.redactionMarker
|
||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
||||
: rootFixture;
|
||||
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
||||
}
|
||||
|
||||
if (options.daemonEnvContents !== undefined) {
|
||||
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
||||
}
|
||||
|
||||
if (options.gatewayLocalEnvContents !== undefined) {
|
||||
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
||||
} else if (options.gatewayLocalTier !== undefined) {
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalEnvPath,
|
||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
||||
tempRoot,
|
||||
);
|
||||
}
|
||||
|
||||
process.env['HOME'] = homePath;
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
delete process.env['MOSAIC_GATEWAY_HOME'];
|
||||
|
||||
await options.setup?.(fixture);
|
||||
|
||||
if (options.inheritedTier !== undefined) {
|
||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||
vi.doMock('node:url', () => ({
|
||||
...nodeUrl,
|
||||
fileURLToPath: (url: string | URL): string => {
|
||||
const actualPath = nodeUrl.fileURLToPath(url);
|
||||
if (
|
||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||
) {
|
||||
return join(anchor, 'env.ts');
|
||||
}
|
||||
return actualPath;
|
||||
},
|
||||
}));
|
||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||
|
||||
if (options.inheritedTier === undefined) {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
||||
} else {
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
||||
}
|
||||
|
||||
const envModule = await import('./env.js');
|
||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
||||
options.expectedProcessTier ?? options.rootTier,
|
||||
);
|
||||
|
||||
const { AppModule } = await import('./app.module.js');
|
||||
const { FederationModule } = await import('./federation/federation.module.js');
|
||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
||||
|
||||
if (!Array.isArray(imports)) {
|
||||
throw new Error('AppModule imports metadata is not an array');
|
||||
}
|
||||
|
||||
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
||||
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
||||
|
||||
if (!Array.isArray(providers)) {
|
||||
throw new Error('ConfigModule providers metadata is not an array');
|
||||
}
|
||||
|
||||
const configProvider = providers
|
||||
.filter(isConfigModuleProvider)
|
||||
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
||||
|
||||
if (!configProvider) {
|
||||
throw new Error('MOSAIC_CONFIG provider factory not found');
|
||||
}
|
||||
|
||||
return {
|
||||
imports,
|
||||
federationModule: FederationModule,
|
||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
||||
args.map((value: unknown): string => String(value)).join(' '),
|
||||
),
|
||||
mosaicConfig: configProvider.useFactory(),
|
||||
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
||||
};
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
vi.doUnmock('node:url');
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppModule federation gating', (): void => {
|
||||
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
||||
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
||||
const envImportIndex = mainSource.indexOf("import './env.js';");
|
||||
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
||||
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
||||
|
||||
expect(envImportIndex).toBeGreaterThan(-1);
|
||||
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
||||
});
|
||||
|
||||
it(
|
||||
'ignores ambient cwd/.env and cwd/../.env files',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, '.env'),
|
||||
'MOSAIC_STORAGE_TIER=federated\n',
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(
|
||||
resolve(fixture.cwdPath, '..', '.env'),
|
||||
'MOSAIC_STORAGE_TIER=federated\n',
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'ignores an ambient cwd/mosaic.config.json federated config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
||||
async (): Promise<void> => {
|
||||
let gatewayLocalConfigPath = '';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
||||
async (): Promise<void> => {
|
||||
let daemonConfigPath = '';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
||||
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
||||
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
||||
await writeFixture(
|
||||
fixture.gatewayLocalConfigPath,
|
||||
configJson('standalone'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
||||
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
||||
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'federated',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
fixture.monorepoRootConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'attributes an invalid monorepo-root dotenv tier to the default',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||
expectedProcessTier: 'invalid',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
||||
inheritedTier: 'local',
|
||||
expectedProcessTier: 'local',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
gatewayLocalTier: 'federated',
|
||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
||||
inheritedTier: 'standalone',
|
||||
expectedProcessTier: 'standalone',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootEnvMode: 'absent',
|
||||
gatewayLocalTier: 'federated',
|
||||
expectedProcessTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'monorepo-root .env wins over gateway-local tier values',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'standalone',
|
||||
gatewayLocalTier: 'federated',
|
||||
});
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each(['local', 'standalone'] as const)(
|
||||
'does not register FederationModule for the %s tier',
|
||||
async (tier): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
||||
|
||||
expect(graph.imports).not.toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
||||
async (): Promise<void> => {
|
||||
const redactionMarker = 'redaction-fixture-marker';
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'federated',
|
||||
redactionMarker,
|
||||
});
|
||||
|
||||
expect(graph.imports).toContain(graph.federationModule);
|
||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
||||
JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
}),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('local');
|
||||
expect(graph.mosaicConfig.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
||||
async (): Promise<void> => {
|
||||
const graph = await loadModuleGraphFromDotenv({
|
||||
rootTier: 'local',
|
||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
||||
await writeFixture(
|
||||
fixture.monorepoRootConfigPath,
|
||||
configJson('federated'),
|
||||
fixture.tempRoot,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
||||
expect(graph.mosaicConfig.storage).toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -21,11 +21,22 @@ import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { PreferencesModule } from './preferences/preferences.module.js';
|
||||
import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { loadConfig } from '@mosaicstack/config';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
|
||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
||||
// federation profile "must not start in non-federated dev"). The gateway
|
||||
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
||||
// configuration is visible here.
|
||||
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -50,10 +61,11 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
HarnessModule,
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
FederationModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
||||
import 'reflect-metadata';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { ChatModule } from './chat.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
HARNESS_REGISTRY,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import {
|
||||
ChatRuntimeUnavailableError,
|
||||
ownConversation,
|
||||
type ChatRuntime,
|
||||
type ChatRuntimeMode,
|
||||
type LegacyEmbeddedChatPort,
|
||||
type LegacyRuntimeStream,
|
||||
type LegacySessionPresentation,
|
||||
type LegacySocketTurnLease,
|
||||
type OwnedConversationContext,
|
||||
} from './chat-runtime.js';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
|
||||
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
|
||||
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
|
||||
* unimplemented stub, so every behavioural assertion below fails until Step Three.
|
||||
*/
|
||||
|
||||
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||
const harness: ChatRuntime = { kind: 'harness' };
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function buildRouter(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a module that was deliberately driven to a fail-closed init.
|
||||
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
|
||||
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
|
||||
* startup error, this time into teardown. Each caller here has already captured and asserted that
|
||||
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
|
||||
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
|
||||
*/
|
||||
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
|
||||
await moduleRef.close().catch((err: unknown) => {
|
||||
if (err instanceof ChatRuntimeUnavailableError) return;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatRuntimeRouter', () => {
|
||||
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
|
||||
// Empty registry + unavailable service: legacy must ignore both and still start.
|
||||
const router = buildRouter('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).not.toThrow();
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
|
||||
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
|
||||
// A failed pi-rpc init must not silently expose the embedded runtime.
|
||||
expect(() => router.active).toThrow();
|
||||
let leaked: ChatRuntime | undefined;
|
||||
try {
|
||||
leaked = router.active;
|
||||
} catch {
|
||||
leaked = undefined;
|
||||
}
|
||||
expect(leaked).not.toBe(embedded);
|
||||
});
|
||||
|
||||
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
|
||||
const router = buildRouter('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
|
||||
try {
|
||||
router.onModuleInit();
|
||||
expect.unreachable('onModuleInit must throw');
|
||||
} catch (err) {
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
|
||||
*
|
||||
* The unit suite above constructs the router but never invokes a legacy port operation, so the
|
||||
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
|
||||
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
|
||||
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
|
||||
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
|
||||
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
|
||||
*
|
||||
* For each operation:
|
||||
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
|
||||
* result AND that the embedded stub was touched zero times (no effects);
|
||||
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
|
||||
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
|
||||
* not vacuously true because the stub could never be called.
|
||||
*
|
||||
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
|
||||
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
|
||||
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
const PRESENTATION: LegacySessionPresentation = {
|
||||
provider: 'embedded-provider',
|
||||
modelId: 'embedded-model',
|
||||
thinkingLevel: 'low',
|
||||
availableThinkingLevels: ['low', 'high'],
|
||||
};
|
||||
|
||||
const stream: LegacyRuntimeStream = {
|
||||
channelId: 'websocket:test-socket',
|
||||
onEvent: () => {},
|
||||
};
|
||||
|
||||
const ctx = (): OwnedConversationContext =>
|
||||
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
|
||||
|
||||
/**
|
||||
* Per-operation invocation counters with declared keys (not an index signature) so each
|
||||
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
|
||||
*/
|
||||
type LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: number;
|
||||
prepareLegacySocketTurn: number;
|
||||
setLegacyThinking: number;
|
||||
abortLegacyTurn: number;
|
||||
applyLegacyModelOverride: number;
|
||||
readLegacySessionPresentation: number;
|
||||
dispatchVerifiedDiscordIngress: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* An embedded port that records every invocation and returns a distinguishable `ok:true`
|
||||
* value per operation. If a router op reaches it (its guard removed), both the recorded call
|
||||
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
|
||||
*/
|
||||
function recordingEmbeddedPort(): {
|
||||
port: ChatRuntime & LegacyEmbeddedChatPort;
|
||||
calls: LegacyPortCallCounts;
|
||||
} {
|
||||
const calls: LegacyPortCallCounts = {
|
||||
completeLegacyRestTurn: 0,
|
||||
prepareLegacySocketTurn: 0,
|
||||
setLegacyThinking: 0,
|
||||
abortLegacyTurn: 0,
|
||||
applyLegacyModelOverride: 0,
|
||||
readLegacySessionPresentation: 0,
|
||||
dispatchVerifiedDiscordIngress: 0,
|
||||
};
|
||||
const lease: LegacySocketTurnLease = {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
};
|
||||
const port: ChatRuntime & LegacyEmbeddedChatPort = {
|
||||
kind: 'embedded',
|
||||
completeLegacyRestTurn: () => {
|
||||
calls.completeLegacyRestTurn += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
|
||||
});
|
||||
},
|
||||
prepareLegacySocketTurn: () => {
|
||||
calls.prepareLegacySocketTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: lease });
|
||||
},
|
||||
setLegacyThinking: () => {
|
||||
calls.setLegacyThinking += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
abortLegacyTurn: () => {
|
||||
calls.abortLegacyTurn += 1;
|
||||
return Promise.resolve({ ok: true, value: undefined });
|
||||
},
|
||||
applyLegacyModelOverride: () => {
|
||||
calls.applyLegacyModelOverride += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
readLegacySessionPresentation: () => {
|
||||
calls.readLegacySessionPresentation += 1;
|
||||
return { ok: true, value: PRESENTATION };
|
||||
},
|
||||
dispatchVerifiedDiscordIngress: () => {
|
||||
calls.dispatchVerifiedDiscordIngress += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
presentation: PRESENTATION,
|
||||
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
dispose: () => Promise.resolve(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return { port, calls };
|
||||
}
|
||||
|
||||
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith(['pi']),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
}
|
||||
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
|
||||
return new ChatRuntimeRouter(
|
||||
registryWith([]),
|
||||
boundConversationService,
|
||||
port,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
}
|
||||
|
||||
// completeLegacyRestTurn ---------------------------------------------------
|
||||
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.completeLegacyRestTurn).toBe(0);
|
||||
});
|
||||
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.completeLegacyRestTurn).toBe(1);
|
||||
});
|
||||
|
||||
// prepareLegacySocketTurn --------------------------------------------------
|
||||
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(0);
|
||||
});
|
||||
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).prepareLegacySocketTurn(
|
||||
ctx(),
|
||||
{ content: 'hello' },
|
||||
stream,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.prepareLegacySocketTurn).toBe(1);
|
||||
});
|
||||
|
||||
// setLegacyThinking (sync) -------------------------------------------------
|
||||
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.setLegacyThinking).toBe(0);
|
||||
});
|
||||
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.setLegacyThinking).toBe(1);
|
||||
});
|
||||
|
||||
// abortLegacyTurn ----------------------------------------------------------
|
||||
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await piRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.abortLegacyTurn).toBe(0);
|
||||
});
|
||||
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = await legacyRouter(port).abortLegacyTurn(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.abortLegacyTurn).toBe(1);
|
||||
});
|
||||
|
||||
// applyLegacyModelOverride (sync) ------------------------------------------
|
||||
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.applyLegacyModelOverride).toBe(0);
|
||||
});
|
||||
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.applyLegacyModelOverride).toBe(1);
|
||||
});
|
||||
|
||||
// readLegacySessionPresentation (sync) -------------------------------------
|
||||
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = piRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result).toEqual(RUNTIME_UNSUPPORTED);
|
||||
expect(calls.readLegacySessionPresentation).toBe(0);
|
||||
});
|
||||
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.readLegacySessionPresentation).toBe(1);
|
||||
});
|
||||
|
||||
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
|
||||
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
|
||||
const { port, calls } = recordingEmbeddedPort();
|
||||
const discordCtx = ctx() as unknown as Parameters<
|
||||
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
|
||||
>[0];
|
||||
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
|
||||
*
|
||||
* The unit suite above constructs the router directly. This group drives the SAME contract
|
||||
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
|
||||
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
|
||||
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
|
||||
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
|
||||
* `onModuleInit` throws a generic Error, so:
|
||||
* - readiness cases fail because the graph never comes up (init rejects), and
|
||||
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
|
||||
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
|
||||
* "throws anything" cannot mask these greens.
|
||||
* The router is NOT wired into a production module yet, so it is provided here via a factory
|
||||
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
|
||||
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
|
||||
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
|
||||
*/
|
||||
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
|
||||
async function bootRouterGraph(
|
||||
mode: ChatRuntimeMode,
|
||||
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
|
||||
) {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (registry: HarnessRegistry) =>
|
||||
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
|
||||
inject: [HARNESS_REGISTRY],
|
||||
},
|
||||
],
|
||||
})
|
||||
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
|
||||
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
|
||||
// group 1 asserts against the genuine production HarnessRegistry.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
// Resolve the production registry singleton and register the requested adapters ON IT, so
|
||||
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
for (const id of opts.adapters) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return moduleRef;
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(harness);
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
|
||||
const moduleRef = await bootRouterGraph('legacy', {
|
||||
adapters: [],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
// Defense-in-depth: the production module wires the genuine registry, empty by default —
|
||||
// guards against a test-double registry silently satisfying the readiness check.
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active).toBe(embedded);
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: ['pi'],
|
||||
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
|
||||
const moduleRef = await bootRouterGraph('pi-rpc', {
|
||||
adapters: [],
|
||||
service: boundConversationService,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
const message = (err as ChatRuntimeUnavailableError).message;
|
||||
expect(message).toBe(
|
||||
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
|
||||
);
|
||||
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
|
||||
*
|
||||
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
|
||||
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
|
||||
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
|
||||
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
|
||||
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
|
||||
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
|
||||
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
|
||||
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
|
||||
*
|
||||
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
|
||||
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
|
||||
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
|
||||
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
|
||||
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
|
||||
* of the two covers "the router is wired through ChatModule to the real registry" without the
|
||||
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
|
||||
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
|
||||
*/
|
||||
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
|
||||
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
|
||||
const resolveImport = (imp: unknown): unknown =>
|
||||
imp &&
|
||||
typeof imp === 'object' &&
|
||||
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
|
||||
? (imp as { forwardRef: () => unknown }).forwardRef()
|
||||
: imp;
|
||||
|
||||
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
|
||||
const providerToken = (provider: unknown): unknown =>
|
||||
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||
|
||||
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
|
||||
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
|
||||
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
|
||||
});
|
||||
|
||||
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
|
||||
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
|
||||
expect(imports.map(resolveImport)).toContain(HarnessModule);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
|
||||
*
|
||||
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
|
||||
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
|
||||
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
|
||||
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
|
||||
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
|
||||
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
|
||||
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
|
||||
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
|
||||
* with an inert value;
|
||||
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
|
||||
* - the HTTP-only `AuthGuard` is stubbed.
|
||||
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
|
||||
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
|
||||
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
|
||||
*
|
||||
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
|
||||
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
|
||||
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
|
||||
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
|
||||
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
|
||||
* conversation-service token (defaulting to the unavailable sentinel).
|
||||
*/
|
||||
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
|
||||
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
|
||||
@Module({})
|
||||
class EmptyCommandsModule {}
|
||||
|
||||
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
|
||||
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: AgentService, useValue: {} }],
|
||||
exports: [AgentService],
|
||||
})
|
||||
class LegacyControllerDepsModule {}
|
||||
|
||||
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
|
||||
* genuine production env contract before providers instantiate. The optional overrides replace
|
||||
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
|
||||
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
|
||||
* the graph — which is exactly why the router-retrieval assertions go red).
|
||||
*/
|
||||
async function bootChatModule(
|
||||
mode: ChatRuntimeMode,
|
||||
overrides: {
|
||||
registryAdapters?: readonly string[];
|
||||
conversationService?: HarnessConversationServiceBinding;
|
||||
} = {},
|
||||
): Promise<TestingModule> {
|
||||
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
else delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
|
||||
let builder = Test.createTestingModule({
|
||||
imports: [LegacyControllerDepsModule, ChatModule],
|
||||
})
|
||||
.overrideModule(CommandsModule)
|
||||
.useModule(EmptyCommandsModule)
|
||||
.overrideProvider(ChatGateway)
|
||||
.useValue({})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true });
|
||||
|
||||
if (overrides.registryAdapters) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWith(overrides.registryAdapters));
|
||||
}
|
||||
if (overrides.conversationService !== undefined) {
|
||||
builder = builder
|
||||
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
|
||||
.useValue(overrides.conversationService);
|
||||
}
|
||||
return builder.compile();
|
||||
}
|
||||
|
||||
// Capture an init rejection without letting a resolved init masquerade as success.
|
||||
const initError = (moduleRef: TestingModule): Promise<unknown> =>
|
||||
moduleRef.init().then(
|
||||
() => new Error('module init resolved but the contract requires it to reject'),
|
||||
(err: unknown) => err,
|
||||
);
|
||||
|
||||
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
|
||||
const moduleRef = await bootChatModule('legacy');
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
|
||||
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
|
||||
expect(registry).toBeInstanceOf(HarnessRegistry);
|
||||
expect(registry.list()).toHaveLength(0);
|
||||
|
||||
const service = moduleRef.get<HarnessConversationServiceBinding>(
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
{
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc');
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
});
|
||||
try {
|
||||
const err = await initError(moduleRef);
|
||||
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
|
||||
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
|
||||
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
|
||||
} finally {
|
||||
await closeIgnoringFailedInit(moduleRef);
|
||||
}
|
||||
});
|
||||
|
||||
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
|
||||
const moduleRef = await bootChatModule('pi-rpc', {
|
||||
registryAdapters: ['pi'],
|
||||
conversationService: boundConversationService,
|
||||
});
|
||||
try {
|
||||
await moduleRef.init();
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('harness');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
|
||||
*
|
||||
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
|
||||
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
|
||||
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
|
||||
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
|
||||
* the disk/network leaves:
|
||||
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
|
||||
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
|
||||
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
|
||||
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
|
||||
* system-rule count — the fake reports rules already present so the seed insert is skipped),
|
||||
* opening no real database;
|
||||
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
|
||||
* storage/auth/log backend is contacted.
|
||||
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
|
||||
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
|
||||
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
|
||||
*
|
||||
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
|
||||
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
|
||||
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
|
||||
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
|
||||
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
|
||||
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
|
||||
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
|
||||
* effect.
|
||||
*/
|
||||
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
|
||||
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
|
||||
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
|
||||
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
|
||||
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
|
||||
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
|
||||
const fakeDb = {
|
||||
$client: { exec: async (): Promise<void> => {} },
|
||||
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||
};
|
||||
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
|
||||
const fakeStorageAdapter = {
|
||||
name: 'fake',
|
||||
migrate: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
};
|
||||
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
|
||||
const fakeProviderService = {
|
||||
onModuleInit: async (): Promise<void> => {},
|
||||
onModuleDestroy: (): void => {},
|
||||
getRegistry: () => ({
|
||||
getAvailable: () => [],
|
||||
getAll: () => [],
|
||||
find: () => undefined,
|
||||
}),
|
||||
getDefaultModel: () => undefined,
|
||||
listAvailableModels: () => [],
|
||||
listProviders: () => [],
|
||||
getAdapter: () => undefined,
|
||||
getProvidersHealth: () => [],
|
||||
};
|
||||
const fakeBrain = { conversations: {}, agents: {} };
|
||||
|
||||
const BOOT_TIMEOUT_MS = 120_000;
|
||||
|
||||
let moduleRef: TestingModule;
|
||||
let envSnapshot: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
envSnapshot = { ...process.env };
|
||||
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['DISCORD_BOT_TOKEN'];
|
||||
delete process.env['TELEGRAM_BOT_TOKEN'];
|
||||
delete process.env['MCP_SERVERS'];
|
||||
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
|
||||
process.env['MOSAIC_STORAGE_TIER'] = 'local';
|
||||
|
||||
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
|
||||
.overrideProvider('DB_HANDLE')
|
||||
.useValue(fakeDbHandle)
|
||||
.overrideProvider('DB')
|
||||
.useValue(fakeDb)
|
||||
.overrideProvider('STORAGE_ADAPTER')
|
||||
.useValue(fakeStorageAdapter)
|
||||
.overrideProvider('AUTH')
|
||||
.useValue({})
|
||||
.overrideProvider('BRAIN')
|
||||
.useValue(fakeBrain)
|
||||
.overrideProvider('LOG_SERVICE')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY_ADAPTER')
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue(fakeProviderService)
|
||||
.compile();
|
||||
|
||||
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
|
||||
await moduleRef.init();
|
||||
}, BOOT_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (moduleRef) await moduleRef.close();
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in envSnapshot)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(envSnapshot)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
|
||||
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
|
||||
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
|
||||
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
expect(typeof gateway.broadcastReload).toBe('function');
|
||||
expect(typeof gateway.getModelOverride).toBe('function');
|
||||
expect(typeof gateway.setModelOverride).toBe('function');
|
||||
expect(typeof gateway.broadcastSessionInfo).toBe('function');
|
||||
});
|
||||
|
||||
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
|
||||
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
|
||||
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
|
||||
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
|
||||
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
|
||||
expect(router.active.kind).toBe('embedded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
isHarnessConversationServiceAvailable,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
ChatRuntimeMode,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeResult,
|
||||
LegacyRuntimeStream,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
} from './chat-runtime.js';
|
||||
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
|
||||
|
||||
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
|
||||
const RUNTIME_UNSUPPORTED = {
|
||||
ok: false as const,
|
||||
code: 'runtime_unsupported' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the one live {@link ChatRuntime} for this process and enforces the
|
||||
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
|
||||
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
|
||||
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
|
||||
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
|
||||
private readonly mode: ChatRuntimeMode;
|
||||
|
||||
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
|
||||
private resolved: ChatRuntime | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly harnessRegistry: HarnessRegistry,
|
||||
private readonly conversationService: HarnessConversationServiceBinding,
|
||||
private readonly embedded: ChatRuntime,
|
||||
private readonly harness: ChatRuntime,
|
||||
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
|
||||
) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.mode === 'legacy') {
|
||||
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
|
||||
this.resolved = this.embedded;
|
||||
return;
|
||||
}
|
||||
|
||||
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
|
||||
if (!this.harnessRegistry.has('pi')) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('adapter_unavailable');
|
||||
}
|
||||
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
|
||||
this.resolved = undefined;
|
||||
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
|
||||
}
|
||||
|
||||
this.resolved = this.harness;
|
||||
}
|
||||
|
||||
get active(): ChatRuntime {
|
||||
if (this.resolved === undefined) {
|
||||
// Reached only if init has not run or failed closed; never expose a runtime here.
|
||||
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
|
||||
}
|
||||
return this.resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
|
||||
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
|
||||
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
|
||||
*/
|
||||
get runtimeMode(): ChatRuntimeMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
|
||||
* verified-Discord op in both modes), where the injected runtime is always a real
|
||||
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
|
||||
* but never invokes a port op, so this narrowing is never exercised against the stub.
|
||||
*/
|
||||
private get embeddedPort(): LegacyEmbeddedChatPort {
|
||||
return this.embedded as unknown as LegacyEmbeddedChatPort;
|
||||
}
|
||||
|
||||
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
|
||||
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.completeLegacyRestTurn(context, input);
|
||||
}
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
|
||||
}
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.setLegacyThinking(context, level);
|
||||
}
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return Promise.resolve(RUNTIME_UNSUPPORTED);
|
||||
}
|
||||
return this.embeddedPort.abortLegacyTurn(context);
|
||||
}
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
|
||||
}
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
if (this.mode === 'pi-rpc') {
|
||||
return RUNTIME_UNSUPPORTED;
|
||||
}
|
||||
return this.embeddedPort.readLegacySessionPresentation(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
|
||||
* never reaches the harness or routing-engine selection. It is reached only through a
|
||||
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
|
||||
*/
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
||||
|
||||
/**
|
||||
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
||||
*
|
||||
* Exactly one runtime is live per process. There is no union that lets a
|
||||
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
||||
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
||||
*/
|
||||
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
||||
|
||||
export type ChatRuntimeKind = 'embedded' | 'harness';
|
||||
|
||||
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
||||
export interface ChatRuntime {
|
||||
readonly kind: ChatRuntimeKind;
|
||||
}
|
||||
|
||||
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
||||
export type ChatRuntimeUnavailableReason =
|
||||
| 'adapter_unavailable'
|
||||
| 'conversation_service_unavailable';
|
||||
|
||||
/**
|
||||
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
||||
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
||||
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
||||
*/
|
||||
export class ChatRuntimeUnavailableError extends Error {
|
||||
readonly code = 'runtime_unsupported' as const;
|
||||
readonly reason: ChatRuntimeUnavailableReason;
|
||||
|
||||
constructor(reason: ChatRuntimeUnavailableReason) {
|
||||
super(
|
||||
reason === 'adapter_unavailable'
|
||||
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
||||
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
||||
);
|
||||
this.name = 'ChatRuntimeUnavailableError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
||||
*/
|
||||
export function resolveChatRuntimeMode(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): ChatRuntimeMode {
|
||||
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transitional embedded chat port (Task Five).
|
||||
//
|
||||
// The legacy embedded browser behaviour is moved behind this exact interface so
|
||||
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
||||
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
||||
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
||||
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
||||
// browser operations when the mode is `pi-rpc`.
|
||||
//
|
||||
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
||||
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
||||
// transport/trust capabilities — there is deliberately no generic
|
||||
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
||||
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
||||
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
||||
*/
|
||||
declare const ownedConversationContextBrand: unique symbol;
|
||||
|
||||
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
||||
export interface OwnedConversationContext {
|
||||
readonly [ownedConversationContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
||||
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
||||
* failures are total results and never throw.
|
||||
*/
|
||||
export type LegacyRuntimeFailure =
|
||||
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly code: 'thinking_level_invalid';
|
||||
readonly retryable: false;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
}
|
||||
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
||||
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
||||
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
||||
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
||||
|
||||
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
||||
export type LegacyRuntimeResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| LegacyRuntimeFailure;
|
||||
|
||||
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
||||
export interface LegacySessionPresentation {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly availableThinkingLevels: readonly string[];
|
||||
readonly agentName?: string;
|
||||
readonly routingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
|
||||
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
||||
export interface LegacyUsage {
|
||||
readonly provider: string;
|
||||
readonly modelId: string;
|
||||
readonly thinkingLevel: string;
|
||||
readonly tokens: Readonly<{
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
total: number;
|
||||
}>;
|
||||
readonly cost: number;
|
||||
readonly context: Readonly<{ percent: number | null; window: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
||||
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
||||
*/
|
||||
export type LegacyRuntimeEvent =
|
||||
| { readonly type: 'started' }
|
||||
| { readonly type: 'text_delta'; readonly text: string }
|
||||
| { readonly type: 'thinking_delta'; readonly text: string }
|
||||
| {
|
||||
readonly type: 'tool_started';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'tool_finished';
|
||||
readonly toolCallId: string;
|
||||
readonly toolName: string;
|
||||
readonly isError: boolean;
|
||||
}
|
||||
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
||||
|
||||
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
||||
export interface LegacyBrowserMessagePayload {
|
||||
readonly content: string;
|
||||
readonly provider?: string;
|
||||
readonly modelId?: string;
|
||||
readonly agentId?: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
||||
export interface LegacySocketTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
/**
|
||||
* Atomically one-shot and scope-rechecking. A second call returns
|
||||
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
||||
*/
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
||||
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
||||
* forced-scope, and attachment-normalization checks.
|
||||
*/
|
||||
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
||||
|
||||
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
||||
export interface VerifiedDiscordIngressContext {
|
||||
readonly [verifiedDiscordIngressContextBrand]: true;
|
||||
readonly conversationId: string;
|
||||
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
||||
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
||||
readonly content: string;
|
||||
readonly attachments?: readonly ChannelAttachmentDto[];
|
||||
readonly correlationId: string;
|
||||
readonly discordMessageId: string;
|
||||
readonly discordUserId: string;
|
||||
}
|
||||
|
||||
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
||||
export interface VerifiedDiscordTurnLease {
|
||||
readonly presentation: LegacySessionPresentation;
|
||||
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Server-owned egress projection the runtime pushes normalized events into. */
|
||||
export interface LegacyRuntimeStream {
|
||||
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
||||
readonly channelId: string;
|
||||
onEvent(event: LegacyRuntimeEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
||||
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
||||
* browser operations under `pi-rpc`.
|
||||
*/
|
||||
export interface LegacyEmbeddedChatPort {
|
||||
completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
>;
|
||||
|
||||
prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation>;
|
||||
|
||||
dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
||||
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
||||
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
||||
*/
|
||||
export function ownConversation(
|
||||
conversationId: string,
|
||||
scope: Readonly<{ userId: string; tenantId: string }>,
|
||||
): OwnedConversationContext {
|
||||
return { conversationId, scope } as unknown as OwnedConversationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
||||
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
||||
* configured-agent, forced-scope, attachment normalization) before calling this.
|
||||
*/
|
||||
export function verifyDiscordIngress(
|
||||
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
||||
): VerifiedDiscordIngressContext {
|
||||
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
// 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 },
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ChannelAttachmentDto } from '@mosaicstack/types';
|
||||
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ChatRequestDto {
|
||||
@IsOptional()
|
||||
@@ -37,3 +46,56 @@ export class ChatSocketMessageDto {
|
||||
/** Validated channel attachment references; binary content is not embedded. */
|
||||
attachments?: readonly ChannelAttachmentDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
|
||||
*
|
||||
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
|
||||
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
|
||||
* fails `@IsString` (undefined is not a string) rather than silently passing.
|
||||
*/
|
||||
export class HarnessTurnSelectionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
harnessId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
modelId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
|
||||
*
|
||||
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
|
||||
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
|
||||
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
|
||||
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
|
||||
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
|
||||
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
|
||||
*/
|
||||
export class HarnessTurnSendDto {
|
||||
@IsUUID()
|
||||
conversationId!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(10_000)
|
||||
content!: string;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => HarnessTurnSelectionDto)
|
||||
selection!: HarnessTurnSelectionDto;
|
||||
|
||||
@IsUUID('4')
|
||||
idempotencyKey!: string;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,31 @@ const payload: SlashCommandPayload = {
|
||||
approvalId: 'approval-1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
|
||||
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
|
||||
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
|
||||
* loudly instead of silently passing. Because execute/approval run entirely through the
|
||||
* command executor dependency and never resolve a chat runtime, this fixture is never
|
||||
* triggered and the ingress stays a GREEN control.
|
||||
*/
|
||||
function failIfUsedChatRuntimeRouter() {
|
||||
return {
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the command approval path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the command approval path');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
return new ChatGateway(
|
||||
{} as never,
|
||||
failIfUsedChatRuntimeRouter() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -72,3 +91,114 @@ describe('ChatGateway command approval ingress', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
|
||||
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
|
||||
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
|
||||
* fall-through to embedded execution. Only runtime-independent audited system commands
|
||||
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
|
||||
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
|
||||
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
|
||||
*/
|
||||
function buildPiRpcGateway(commandExecutor: {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
createApproval: ReturnType<typeof vi.fn>;
|
||||
}): ChatGateway {
|
||||
const piRpcRouter = {
|
||||
runtimeMode: 'pi-rpc' as const,
|
||||
onModuleInit: () => {
|
||||
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
|
||||
},
|
||||
get active(): never {
|
||||
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
|
||||
},
|
||||
};
|
||||
return new ChatGateway(
|
||||
piRpcRouter as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
commandExecutor as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
|
||||
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
|
||||
|
||||
it.each(['model', 'agent', 'gc'])(
|
||||
'fails /%s closed before the executor under pi-rpc (execute never called)',
|
||||
async (command): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', {
|
||||
command,
|
||||
conversationId: 'conversation-1',
|
||||
success: false,
|
||||
message: UNSUPPORTED,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
|
||||
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
|
||||
const commandExecutor = {
|
||||
execute: vi.fn().mockResolvedValue(reloadResult),
|
||||
createApproval: vi.fn(),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandExecute(client as never, {
|
||||
command: 'reload',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(commandExecutor.execute).toHaveBeenCalledWith(
|
||||
{ command: 'reload', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
|
||||
});
|
||||
|
||||
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
|
||||
const commandExecutor = {
|
||||
execute: vi.fn(),
|
||||
createApproval: vi.fn().mockResolvedValue({
|
||||
approvalId: 'approval-1',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
}),
|
||||
};
|
||||
const gateway = buildPiRpcGateway(commandExecutor);
|
||||
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||
|
||||
await gateway.handleCommandApproval(client as never, {
|
||||
command: 'gc',
|
||||
conversationId: 'conversation-1',
|
||||
});
|
||||
|
||||
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||
{ command: 'gc', conversationId: 'conversation-1' },
|
||||
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'command:approval',
|
||||
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,59 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import {
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
HARNESS_REGISTRY,
|
||||
type HarnessConversationServiceBinding,
|
||||
} from '../harness/harness.tokens.js';
|
||||
import type { HarnessConversationService } from '@mosaicstack/types';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
import { ChatRuntimeRouter } from './chat-runtime-router.js';
|
||||
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
|
||||
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||
|
||||
/**
|
||||
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
|
||||
* authority: the controller and gateway inject only the router, never `AgentService`,
|
||||
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
|
||||
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
|
||||
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
|
||||
*
|
||||
* The router and the harness runtime are constructed through factories because their
|
||||
* dependencies are interface/union types with no runtime injection token (the registry and
|
||||
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
|
||||
* runtime injects the class-typed `AgentService` and is provided directly.
|
||||
*/
|
||||
@Module({
|
||||
imports: [forwardRef(() => CommandsModule)],
|
||||
imports: [forwardRef(() => CommandsModule), HarnessModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
exports: [ChatGateway],
|
||||
providers: [
|
||||
ChatGateway,
|
||||
EmbeddedChatRuntime,
|
||||
{
|
||||
provide: HarnessChatRuntime,
|
||||
useFactory: (conversationService: HarnessConversationServiceBinding) =>
|
||||
new HarnessChatRuntime(conversationService as HarnessConversationService),
|
||||
inject: [HARNESS_CONVERSATION_SERVICE],
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (
|
||||
registry: HarnessRegistry,
|
||||
conversationService: HarnessConversationServiceBinding,
|
||||
embedded: EmbeddedChatRuntime,
|
||||
harness: HarnessChatRuntime,
|
||||
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
|
||||
inject: [
|
||||
HARNESS_REGISTRY,
|
||||
HARNESS_CONVERSATION_SERVICE,
|
||||
EmbeddedChatRuntime,
|
||||
HarnessChatRuntime,
|
||||
],
|
||||
},
|
||||
],
|
||||
exports: [ChatGateway, ChatRuntimeRouter],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeEvent,
|
||||
LegacyRuntimeResult,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
LegacyUsage,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
LegacyRuntimeStream,
|
||||
} from './chat-runtime.js';
|
||||
|
||||
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||
*
|
||||
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||
readonly kind = 'embedded' as const;
|
||||
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||
|
||||
constructor(readonly agentService: AgentService) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy REST completion (op A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let responseText = '';
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let detach: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
|
||||
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
|
||||
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
|
||||
// would return while leaving the listener attached (free to consume a later turn's events) and
|
||||
// the 120s timer live (its rejection later going unobserved).
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
detach?.();
|
||||
};
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
dispose();
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, REST_TURN_TIMEOUT_MS);
|
||||
|
||||
detach = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
dispose();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
|
||||
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
|
||||
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
|
||||
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
|
||||
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
|
||||
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
|
||||
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
|
||||
const prompting = this.agentService.prompt(conversationId, input.content, scope);
|
||||
try {
|
||||
await Promise.all([prompting, done]);
|
||||
} catch (err) {
|
||||
dispose();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
return { ok: false, code: 'timeout', retryable: true };
|
||||
}
|
||||
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
|
||||
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||
return { ok: true, value: { text: responseText, presentation } };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy Socket streaming (op B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||
});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded socket subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
input.content,
|
||||
input.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Thinking level (op C) — synchronous, total
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
setLegacyThinking(
|
||||
context: OwnedConversationContext,
|
||||
level: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
|
||||
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'thinking_level_invalid',
|
||||
retryable: false,
|
||||
availableThinkingLevels,
|
||||
};
|
||||
}
|
||||
|
||||
session.piSession.setThinkingLevel(level as never);
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Abort (op D)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
try {
|
||||
await session.piSession.abort();
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy abort failed for conversation=${context.conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model override (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
applyLegacyModelOverride(
|
||||
context: OwnedConversationContext,
|
||||
modelId: string,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
|
||||
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
|
||||
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
|
||||
return { ok: true, value: this.presentationForSession(refreshed) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Presentation read (synchronous, total)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
readLegacySessionPresentation(
|
||||
context: OwnedConversationContext,
|
||||
): LegacyRuntimeResult<LegacySessionPresentation> {
|
||||
const scope = toScope(context.scope);
|
||||
const session = this.agentService.getSession(context.conversationId, scope);
|
||||
if (!session) return CONVERSATION_UNAVAILABLE;
|
||||
return { ok: true, value: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Verified Discord ingress (embedded-only in both modes)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async dispatchVerifiedDiscordIngress(
|
||||
context: VerifiedDiscordIngressContext,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(
|
||||
conversationId,
|
||||
scope,
|
||||
{ agentConfigId: context.configuredAgent.agentConfigId },
|
||||
{
|
||||
agentConfigId: context.configuredAgent.agentConfigId,
|
||||
instanceId: context.configuredAgent.instanceId,
|
||||
},
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let detach: () => void;
|
||||
try {
|
||||
detach = this.subscribe(conversationId, scope, stream);
|
||||
} catch (err) {
|
||||
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
|
||||
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
|
||||
this.logger.error(
|
||||
`Embedded Discord subscription failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: this.buildLease(
|
||||
conversationId,
|
||||
scope,
|
||||
context.content,
|
||||
context.attachments,
|
||||
detach,
|
||||
resolved.presentation,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the owned session, creating it on first use. Ownership/scope rejections
|
||||
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
|
||||
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
|
||||
* session presentation so callers avoid a redundant `getSession`.
|
||||
*/
|
||||
private async resolveOrCreate(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
|
||||
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||
> {
|
||||
// A verified-Discord turn may only run under a session whose configured identity matches the
|
||||
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
|
||||
// pre-existing session AND a freshly created one: a session carrying a different configured
|
||||
// agent — however it arose — is rejected rather than executed under the verified label, so we
|
||||
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
|
||||
// no expectedAgent and skips the check.
|
||||
const identityMatches = (candidate: AgentSession): boolean =>
|
||||
expectedAgent === undefined ||
|
||||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
|
||||
candidate.agentName === expectedAgent.instanceId);
|
||||
|
||||
let session = this.agentService.getSession(conversationId, scope);
|
||||
if (session && !identityMatches(session)) {
|
||||
// Reused same-scope session minted under a different configured identity — reject with zero
|
||||
// effects rather than dispatch a verified turn onto a foreign agent's session.
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
if (!session) {
|
||||
try {
|
||||
session = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
...extraOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
this.logger.error(
|
||||
`Embedded session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
// The just-created session must ALSO carry the reconciled identity before any effect. A
|
||||
// createSession that returns a session under a different configured agent (misconfiguration
|
||||
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
|
||||
if (!identityMatches(session)) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
}
|
||||
return { ok: true, presentation: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||
private subscribe(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
stream: LegacyRuntimeStream,
|
||||
): () => void {
|
||||
const unsubscribe = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||
if (normalized) stream.onEvent(normalized);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
try {
|
||||
this.agentService.addChannel(conversationId, stream.channelId, scope);
|
||||
} catch (err) {
|
||||
// Partial setup: the listener was acquired but the channel attach failed. Roll back
|
||||
// exactly what was acquired (the listener) before the failure escapes, so no leaked
|
||||
// subscription survives; the caller converts the rethrow into a total safe failure.
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
try {
|
||||
this.agentService.removeChannel(conversationId, stream.channelId, scope);
|
||||
} catch {
|
||||
/* idempotent teardown */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
|
||||
private buildLease(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
content: string,
|
||||
attachments: VerifiedDiscordIngressContext['attachments'],
|
||||
detach: () => void,
|
||||
presentation: LegacySessionPresentation,
|
||||
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
|
||||
let dispatched = false;
|
||||
let disposed = false;
|
||||
return {
|
||||
presentation,
|
||||
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
|
||||
if (dispatched) {
|
||||
return { ok: false, code: 'turn_already_dispatched', retryable: false };
|
||||
}
|
||||
dispatched = true;
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, content, scope, attachments);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Legacy dispatch failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
dispose: async (): Promise<void> => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
|
||||
private normalizeEvent(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
event: AgentSessionEvent,
|
||||
): LegacyRuntimeEvent | undefined {
|
||||
switch (event.type) {
|
||||
case 'agent_start':
|
||||
return { type: 'started' };
|
||||
case 'agent_end':
|
||||
return { type: 'settled', ...this.usageFor(conversationId, scope) };
|
||||
case 'message_update': {
|
||||
const assistant = event.assistantMessageEvent;
|
||||
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
|
||||
if (assistant.type === 'thinking_delta') {
|
||||
return { type: 'thinking_delta', text: assistant.delta };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case 'tool_execution_start':
|
||||
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
|
||||
case 'tool_execution_end':
|
||||
return {
|
||||
type: 'tool_finished',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
isError: event.isError,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers terminal usage from the Pi session and records it into session metrics.
|
||||
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
|
||||
*/
|
||||
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const piSession = session?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
if (!session || !stats) return {};
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
const tokens = {
|
||||
input: stats.tokens?.input ?? 0,
|
||||
output: stats.tokens?.output ?? 0,
|
||||
cacheRead: stats.tokens?.cacheRead ?? 0,
|
||||
cacheWrite: stats.tokens?.cacheWrite ?? 0,
|
||||
total: stats.tokens?.total ?? 0,
|
||||
};
|
||||
|
||||
this.agentService.recordTokenUsage(conversationId, { ...tokens });
|
||||
|
||||
return {
|
||||
usage: {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||
tokens,
|
||||
cost: stats.cost ?? 0,
|
||||
context: {
|
||||
percent: contextUsage?.percent ?? null,
|
||||
window: contextUsage?.contextWindow ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Presentation from a live session id, or undefined when no owned session exists. */
|
||||
private presentationFor(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): LegacySessionPresentation | undefined {
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
return session ? this.presentationForSession(session) : undefined;
|
||||
}
|
||||
|
||||
/** User-facing projection carrying no session handle, credential, or raw stats. */
|
||||
private presentationForSession(session: AgentSession): LegacySessionPresentation {
|
||||
return {
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
thinkingLevel: session.piSession.thinkingLevel,
|
||||
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
|
||||
const CONVERSATION_UNAVAILABLE = {
|
||||
ok: false as const,
|
||||
code: 'conversation_unavailable' as const,
|
||||
retryable: false as const,
|
||||
};
|
||||
|
||||
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
|
||||
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
|
||||
return { userId: scope.userId, tenantId: scope.tenantId };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessActorContext,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
HarnessSelection,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import { HarnessChatRuntime } from './harness-chat.runtime.js';
|
||||
|
||||
/**
|
||||
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
|
||||
* exclusively through the {@link HarnessConversationService} RPC boundary and
|
||||
* forwards the caller's exact selection tuple and idempotency key without
|
||||
* substitution. Red-first: the runtime is an unimplemented stub, so every
|
||||
* delegation assertion fails until Step Three.
|
||||
*/
|
||||
|
||||
const context: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude-opus-4-8',
|
||||
};
|
||||
|
||||
const conversationId = '11111111-1111-4111-8111-111111111111';
|
||||
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
|
||||
context,
|
||||
conversationId,
|
||||
selection,
|
||||
turnId: 'turn-abc',
|
||||
correlationId: 'corr-1',
|
||||
content: 'hello',
|
||||
idempotencyKey,
|
||||
};
|
||||
|
||||
const attachInput: AttachConversation & { afterSequence?: number } = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
selection,
|
||||
afterSequence: 0,
|
||||
};
|
||||
|
||||
const detachInput: DetachConversation = {
|
||||
context,
|
||||
conversationId,
|
||||
clientId: 'client-1',
|
||||
};
|
||||
|
||||
interface RecordedCalls {
|
||||
attach: (AttachConversation & { afterSequence?: number })[];
|
||||
detach: DetachConversation[];
|
||||
send: (SendHarnessTurn & { idempotencyKey: string })[];
|
||||
subscribeFrom: { conversationId: string; afterSequence: number }[];
|
||||
}
|
||||
|
||||
const snapshot: ConversationSnapshot = {
|
||||
session: {
|
||||
conversationId,
|
||||
nativeSessionId: 'native-1',
|
||||
seatId: 'seat-1',
|
||||
selection,
|
||||
state: 'idle',
|
||||
attachedClientIds: ['client-1'],
|
||||
},
|
||||
lastSequence: 0,
|
||||
replay: [],
|
||||
};
|
||||
|
||||
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
|
||||
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
|
||||
const service: HarnessConversationService = {
|
||||
attach: (input) => {
|
||||
calls.attach.push(input);
|
||||
return Promise.resolve(snapshot);
|
||||
},
|
||||
detach: (input) => {
|
||||
calls.detach.push(input);
|
||||
return Promise.resolve();
|
||||
},
|
||||
send: (input) => {
|
||||
calls.send.push(input);
|
||||
// The service echoes only the requested tuple; there is no representable substitute.
|
||||
const receipt: TurnReceipt = {
|
||||
conversationId: input.conversationId,
|
||||
turnId: 'turn-server',
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: input.selection,
|
||||
};
|
||||
return Promise.resolve(receipt);
|
||||
},
|
||||
subscribeFrom: (id, afterSequence) => {
|
||||
calls.subscribeFrom.push({ conversationId: id, afterSequence });
|
||||
|
||||
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
|
||||
return;
|
||||
})();
|
||||
},
|
||||
};
|
||||
return { runtime: new HarnessChatRuntime(service), calls };
|
||||
}
|
||||
|
||||
describe('HarnessChatRuntime', () => {
|
||||
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
|
||||
const { runtime } = build();
|
||||
expect(runtime.kind).toBe('harness');
|
||||
});
|
||||
|
||||
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const receipt = await runtime.send(sendInput);
|
||||
|
||||
expect(calls.send).toHaveLength(1);
|
||||
const firstSend = calls.send[0]!;
|
||||
expect(firstSend).toEqual(sendInput);
|
||||
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
|
||||
expect(firstSend.selection).toEqual(selection);
|
||||
// The runtime must not substitute an effective tuple onto the receipt.
|
||||
expect(receipt.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('delegates attach to the conversation service and returns its snapshot', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const result = await runtime.attach(attachInput);
|
||||
|
||||
expect(calls.attach).toHaveLength(1);
|
||||
expect(calls.attach[0]).toEqual(attachInput);
|
||||
expect(result).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('delegates detach to the conversation service', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
await runtime.detach(detachInput);
|
||||
|
||||
expect(calls.detach).toHaveLength(1);
|
||||
expect(calls.detach[0]).toEqual(detachInput);
|
||||
});
|
||||
|
||||
it('delegates subscribeFrom to the conversation service journal replay', async () => {
|
||||
const { runtime, calls } = build();
|
||||
|
||||
const iterable = runtime.subscribeFrom(conversationId, 7);
|
||||
// Drain to prove it is the service-backed async iterable, not a fabricated one.
|
||||
const drained: unknown[] = [];
|
||||
for await (const event of iterable) {
|
||||
drained.push(event);
|
||||
}
|
||||
expect(drained).toHaveLength(0);
|
||||
|
||||
expect(calls.subscribeFrom).toHaveLength(1);
|
||||
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AttachConversation,
|
||||
ConversationSnapshot,
|
||||
DetachConversation,
|
||||
HarnessConversationService,
|
||||
HarnessEventEnvelope,
|
||||
SendHarnessTurn,
|
||||
TurnReceipt,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ChatRuntime } from './chat-runtime.js';
|
||||
|
||||
/**
|
||||
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
|
||||
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
|
||||
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
|
||||
* stack, and it forwards the caller's exact selection tuple and idempotency key
|
||||
* without substitution.
|
||||
*
|
||||
* It owns no state and adds no policy: every method forwards the caller's exact
|
||||
* argument to the injected {@link HarnessConversationService} and returns its
|
||||
* result unchanged, so the requested selection tuple and idempotency key can
|
||||
* never be substituted on the way through.
|
||||
*/
|
||||
export class HarnessChatRuntime implements ChatRuntime {
|
||||
readonly kind = 'harness' as const;
|
||||
|
||||
constructor(private readonly conversations: HarnessConversationService) {}
|
||||
|
||||
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
|
||||
return this.conversations.attach(input);
|
||||
}
|
||||
|
||||
detach(input: DetachConversation): Promise<void> {
|
||||
return this.conversations.detach(input);
|
||||
}
|
||||
|
||||
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
|
||||
return this.conversations.send(input);
|
||||
}
|
||||
|
||||
subscribeFrom(
|
||||
conversationId: string,
|
||||
afterSequence: number,
|
||||
): AsyncIterable<HarnessEventEnvelope> {
|
||||
return this.conversations.subscribeFrom(conversationId, afterSequence);
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,19 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
reconnectServer: ReturnType<typeof vi.fn>;
|
||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||
} | null = null,
|
||||
} = mockMcpClient,
|
||||
): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
|
||||
@@ -36,6 +36,12 @@ const authorization = {
|
||||
),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
@@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mockMcpClient as never,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
@@ -548,15 +546,6 @@ export class CommandExecutorService {
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!this.mcpClient) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'MCP client service is not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||
|
||||
switch (action) {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* - Unknown command returns descriptive error
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandsModule } from './commands.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -47,6 +49,12 @@ const mockBrain = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
mockBrain as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
null, // mcpClient (optional)
|
||||
mockMcpClient as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +161,15 @@ describe('CommandRegistryService — integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandsModule — Nest wiring', () => {
|
||||
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
||||
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
||||
expect(imports).toContain(McpClientModule);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandExecutorService — integration', () => {
|
||||
@@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => {
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
// /mcp status reaches the required McpClientService and never reports it unavailable
|
||||
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('No MCP servers configured.');
|
||||
expect(result.message).not.toBe('MCP client service is not available.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
@@ -14,7 +15,12 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||
imports: [
|
||||
GCModule,
|
||||
McpClientModule,
|
||||
forwardRef(() => ReloadModule),
|
||||
forwardRef(() => ChatModule),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||
import { resolveGatewayConfigPath } from '../env.js';
|
||||
|
||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
|
||||
@@ -8,7 +9,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||
providers: [
|
||||
{
|
||||
provide: MOSAIC_CONFIG,
|
||||
useFactory: (): MosaicConfig => loadConfig(),
|
||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
||||
},
|
||||
],
|
||||
exports: [MOSAIC_CONFIG],
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
/**
|
||||
* Task 5 harness fence for the conversations REST write path.
|
||||
*
|
||||
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
|
||||
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
|
||||
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
|
||||
*
|
||||
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
|
||||
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
|
||||
* `process.env` at request time. The two "env is flipped after construction" tests below are the
|
||||
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
|
||||
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
|
||||
*/
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER = { id: 'user-1' };
|
||||
|
||||
function sendMessageDto() {
|
||||
return {
|
||||
role: 'user' as const,
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function brainWithMessageSpy() {
|
||||
const addMessage = vi.fn().mockResolvedValue({
|
||||
id: 'message-1',
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
});
|
||||
return {
|
||||
brain: { conversations: { addMessage } } as never,
|
||||
addMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
|
||||
function routerFixedTo(mode: ChatRuntimeMode) {
|
||||
return { runtimeMode: mode };
|
||||
}
|
||||
|
||||
let priorMode: string | undefined;
|
||||
|
||||
describe('conversations REST write path — Task 5 harness fence', () => {
|
||||
beforeEach(() => {
|
||||
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||
});
|
||||
|
||||
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
|
||||
// written, so no duplicate message can be produced.
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
},
|
||||
USER.id,
|
||||
);
|
||||
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
|
||||
});
|
||||
|
||||
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
|
||||
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
|
||||
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
|
||||
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
|
||||
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
@@ -19,6 +20,7 @@ import type { Brain } from '@mosaicstack/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
UpdateConversationDto,
|
||||
@@ -26,10 +28,41 @@ import {
|
||||
SearchMessagesDto,
|
||||
} from './conversations.dto.js';
|
||||
|
||||
/**
|
||||
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
|
||||
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
|
||||
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
|
||||
*/
|
||||
class HarnessRuntimeWriteUnsupportedException extends HttpException {
|
||||
readonly code = 'runtime_unsupported' as const;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
{
|
||||
code: 'runtime_unsupported',
|
||||
message:
|
||||
'Conversation message writes are handled by the harness runtime on this deployment.',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/conversations')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ConversationsController {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
/**
|
||||
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
|
||||
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
|
||||
* request time — a single source of truth, so the controller cannot disagree with the router
|
||||
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
|
||||
* so this class depends on nothing else the router exposes.
|
||||
*/
|
||||
constructor(
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: { id: string }) {
|
||||
@@ -94,6 +127,13 @@ export class ConversationsController {
|
||||
@Body() dto: SendMessageDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
|
||||
// harness path owns persistence there, so a direct write would duplicate the message. The mode
|
||||
// comes from the router's init-time resolution, not a request-time env read.
|
||||
if (this.router.runtimeMode === 'pi-rpc') {
|
||||
throw new HarnessRuntimeWriteUnsupportedException();
|
||||
}
|
||||
|
||||
const message = await this.brain.conversations.addMessage(
|
||||
{
|
||||
conversationId: id,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
/**
|
||||
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
|
||||
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
|
||||
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
controllers: [ConversationsController],
|
||||
})
|
||||
export class ConversationsModule {}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CoordModule } from './coord.module.js';
|
||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
|
||||
describe('CoordModule DI (compiled-metadata boot)', () => {
|
||||
it('resolves InteractionCoordinationService through Nest DI', async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: (): boolean => true })
|
||||
.compile();
|
||||
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
|
||||
InteractionCoordinationService,
|
||||
);
|
||||
await moduleRef.close();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import {
|
||||
InteractionCoordinationClient,
|
||||
type CoordinationObservation,
|
||||
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
||||
|
||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
||||
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
||||
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
|
||||
|
||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
|
||||
constructor(
|
||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
||||
@Optional()
|
||||
@Inject(HANDOFF_ID_FACTORY)
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
||||
|
||||
type TierSource =
|
||||
| 'process environment'
|
||||
| 'daemon .env'
|
||||
| 'monorepo-root .env'
|
||||
| 'gateway-local .env'
|
||||
| 'default';
|
||||
|
||||
type BootSource = TierSource | 'mosaic.config.json';
|
||||
|
||||
export interface GatewayDotenvPaths {
|
||||
daemonEnv: string;
|
||||
monorepoRootEnv: string;
|
||||
gatewayLocalEnv: string;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function resolveGatewayDotenvPaths(
|
||||
anchor: string = here,
|
||||
homeBase: string = homedir(),
|
||||
): GatewayDotenvPaths {
|
||||
return {
|
||||
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
||||
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
||||
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGatewayConfigPath(anchor: string = here): string {
|
||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
||||
const gatewayHome = resolve(
|
||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
||||
);
|
||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||
|
||||
if (existsSync(daemonConfig)) {
|
||||
return daemonConfig;
|
||||
}
|
||||
if (existsSync(gatewayLocalConfig)) {
|
||||
return gatewayLocalConfig;
|
||||
}
|
||||
if (existsSync(monorepoRootConfig)) {
|
||||
return monorepoRootConfig;
|
||||
}
|
||||
|
||||
return monorepoRootConfig;
|
||||
}
|
||||
|
||||
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
||||
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
||||
anchor,
|
||||
homeBase,
|
||||
);
|
||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
||||
let databaseUrlSource: TierSource =
|
||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
||||
|
||||
function loadAnchoredDotenv(
|
||||
path: string,
|
||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
||||
): void {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
||||
config({ path, quiet: true });
|
||||
|
||||
if (
|
||||
beforeTier === undefined &&
|
||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
||||
tierSource === 'default'
|
||||
) {
|
||||
tierSource = sourceLabel;
|
||||
}
|
||||
|
||||
if (
|
||||
beforeDatabaseUrl === undefined &&
|
||||
process.env['DATABASE_URL'] !== undefined &&
|
||||
databaseUrlSource === 'default'
|
||||
) {
|
||||
databaseUrlSource = sourceLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
||||
// It takes precedence over file-based local-dev configuration.
|
||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
||||
|
||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
||||
// from apps/gateway/.env when present.
|
||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
||||
|
||||
const envOnlyTier = detectFromEnv().tier;
|
||||
const configPath = resolveGatewayConfigPath(anchor);
|
||||
const anchoredConfigExists = existsSync(configPath);
|
||||
const resolvedTier = loadConfig(configPath).tier;
|
||||
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
||||
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
||||
const recognizedTierDeterminesTier =
|
||||
(configuredTier === 'federated' ||
|
||||
configuredTier === 'standalone' ||
|
||||
configuredTier === 'local') &&
|
||||
configuredTier === envOnlyTier;
|
||||
|
||||
let source: BootSource;
|
||||
if (anchoredConfigExists) {
|
||||
source = 'mosaic.config.json';
|
||||
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
||||
source = databaseUrlSource;
|
||||
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
||||
source = tierSource;
|
||||
} else {
|
||||
source = 'default';
|
||||
}
|
||||
|
||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
||||
}
|
||||
|
||||
loadGatewayEnv();
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
// Import the REAL module (not a hand-listed controllers+mocks list) so an
|
||||
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
|
||||
import { HarnessModule } from './harness.module.js';
|
||||
|
||||
// A known-available tuple from the fake adapter's default catalog.
|
||||
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
|
||||
// A tuple whose provider/model are not in any catalog.
|
||||
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
|
||||
// A tuple that is known in the catalog but flagged unavailable.
|
||||
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function registryWithFake(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe('Harness selection HTTP surface', () => {
|
||||
let app: INestApplication;
|
||||
let repository: HarnessSelectionRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWithFake())
|
||||
.compile();
|
||||
|
||||
// Real in-memory repository from the module graph — proves the module wired it.
|
||||
repository = moduleRef.get(HarnessSelectionRepository);
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset owner-scoped state between tests via the public API surface.
|
||||
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/chat/preferences/selection')
|
||||
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The returned selection is user-1's (guard-derived scope), not the query's.
|
||||
expect(response.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
|
||||
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
|
||||
|
||||
const put = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(next)
|
||||
.set('Content-Type', 'application/json');
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.selection).toEqual(next);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.selection).toEqual(next);
|
||||
});
|
||||
|
||||
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send({ selection: 'gpt-4o' })
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['seatId', { ...VALID, seatId: 'attacker-seat' }],
|
||||
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
|
||||
['userId', { ...VALID, userId: 'attacker' }],
|
||||
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
|
||||
['executable', { ...VALID, executable: '/usr/bin/evil' }],
|
||||
['home', { ...VALID, home: '/home/attacker' }],
|
||||
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
|
||||
])(
|
||||
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
|
||||
async (_name, body) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(body)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
},
|
||||
);
|
||||
|
||||
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(UNKNOWN)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.code).toBe('selection_invalid');
|
||||
// Echoed back unchanged: no first-row / first-provider substitution.
|
||||
expect(response.body.selection).toEqual(UNKNOWN);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
|
||||
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.put('/api/chat/preferences/selection')
|
||||
.send(UNAVAILABLE)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.code).toBe('model_unavailable');
|
||||
expect(response.body.selection).toEqual(UNAVAILABLE);
|
||||
|
||||
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
|
||||
expect(get.body.selection).toEqual(VALID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
|
||||
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 { HarnessOperationError } from './harness.registry.js';
|
||||
import { HarnessSelectionService } from './harness-selection.service.js';
|
||||
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
|
||||
|
||||
/**
|
||||
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
|
||||
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
|
||||
* query string can never name another user, tenant, or seat. A typed selection
|
||||
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
|
||||
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
|
||||
* unchanged, and never mutates the stored selection.
|
||||
*/
|
||||
@Controller('api/chat/preferences/selection')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HarnessSelectionController {
|
||||
constructor(private readonly selection: HarnessSelectionService) {}
|
||||
|
||||
@Get()
|
||||
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
|
||||
return { selection: this.selection.getSelection(scopeFromUser(user)) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
async put(
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Body() dto: HarnessSelectionInputDto,
|
||||
): Promise<SelectionResponseDto> {
|
||||
try {
|
||||
const stored = await this.selection.setSelection(scopeFromUser(user), {
|
||||
harnessId: dto.harnessId,
|
||||
providerId: dto.providerId,
|
||||
modelId: dto.modelId,
|
||||
});
|
||||
return { selection: stored };
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessOperationError) {
|
||||
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { HarnessSelection } from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistry,
|
||||
operationError,
|
||||
} from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { readContextFromScope } from './harness.dto.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
|
||||
/**
|
||||
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
||||
* requested harness/provider/model tuple against the live catalog with NO
|
||||
* fallback substitution, then persists it owner-scoped. The stored selection is
|
||||
* only ever mutated when the tuple is valid AND available.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessSelectionService {
|
||||
constructor(
|
||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||
private readonly repository: HarnessSelectionRepository,
|
||||
) {}
|
||||
|
||||
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
||||
return this.repository.get(scope);
|
||||
}
|
||||
|
||||
async setSelection(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<HarnessSelection> {
|
||||
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
||||
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
||||
await this.assertSelectionAvailable(scope, selection);
|
||||
return this.repository.set(scope, selection);
|
||||
}
|
||||
|
||||
private async assertSelectionAvailable(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<void> {
|
||||
const correlationId = randomUUID();
|
||||
|
||||
let adapter;
|
||||
try {
|
||||
adapter = this.registry.get(selection.harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const catalog = await adapter.catalog(readContextFromScope(scope));
|
||||
const entry = catalog.models.find(
|
||||
(candidate) =>
|
||||
candidate.harnessId === selection.harnessId &&
|
||||
candidate.providerId === selection.providerId &&
|
||||
candidate.modelId === selection.modelId,
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
type INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
// The real module under test — importing it (not a hand-listed controllers/mocks
|
||||
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
|
||||
import { HarnessModule } from './harness.module.js';
|
||||
|
||||
// Fields that must NEVER surface on a browser-facing catalog/list response.
|
||||
const FORBIDDEN_KEYS = [
|
||||
'executable',
|
||||
'executablePath',
|
||||
'home',
|
||||
'homeDir',
|
||||
'cwd',
|
||||
'workingDir',
|
||||
'workingDirectory',
|
||||
'nativeSessionPath',
|
||||
'sessionPath',
|
||||
'env',
|
||||
'secret',
|
||||
'secrets',
|
||||
'token',
|
||||
'apiKey',
|
||||
];
|
||||
|
||||
function assertNoForbiddenLeak(payload: unknown): void {
|
||||
const serialized = JSON.stringify(payload).toLowerCase();
|
||||
for (const key of FORBIDDEN_KEYS) {
|
||||
expect(serialized).not.toContain(key.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
|
||||
requestContext.user = { id: 'user-1' };
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function registryWithFake(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
return registry;
|
||||
}
|
||||
|
||||
describe('Harness catalog HTTP surface', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [HarnessModule],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.overrideProvider(HARNESS_REGISTRY)
|
||||
.useValue(registryWithFake())
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
|
||||
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
|
||||
// have thrown and this suite would never reach here.
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/harnesses returns 200 with safe fields only', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBeGreaterThan(0);
|
||||
const summary = response.body[0];
|
||||
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
|
||||
expect(summary.id).toBe('fake');
|
||||
expect(typeof summary.displayName).toBe('string');
|
||||
expect(Array.isArray(summary.capabilities)).toBe(true);
|
||||
assertNoForbiddenLeak(response.body);
|
||||
});
|
||||
|
||||
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.harnessId).toBe('fake');
|
||||
expect(typeof response.body.version).toBe('string');
|
||||
expect(typeof response.body.fingerprint).toBe('string');
|
||||
expect(Array.isArray(response.body.models)).toBe(true);
|
||||
expect(response.body.models.length).toBeGreaterThan(0);
|
||||
const entry = response.body.models[0];
|
||||
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
|
||||
expect(Object.keys(entry).sort()).toEqual(
|
||||
[
|
||||
'authState',
|
||||
'availability',
|
||||
'displayName',
|
||||
'harnessId',
|
||||
'inputTypes',
|
||||
'modelId',
|
||||
'providerId',
|
||||
'reasoningCapability',
|
||||
].sort(),
|
||||
);
|
||||
assertNoForbiddenLeak(response.body);
|
||||
});
|
||||
|
||||
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.code).toBe('adapter_unavailable');
|
||||
// A fallback catalog would carry a models array; a typed error must not.
|
||||
expect(response.body.models).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import {
|
||||
readContextFromScope,
|
||||
toHarnessSummary,
|
||||
toSafeCatalog,
|
||||
type HarnessCatalogDto,
|
||||
type HarnessSummaryDto,
|
||||
} from './harness.dto.js';
|
||||
|
||||
/**
|
||||
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
|
||||
* fields (identity, capabilities, provider/model catalog) — never executables,
|
||||
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
|
||||
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
|
||||
*/
|
||||
@Controller('api/harnesses')
|
||||
@UseGuards(AuthGuard)
|
||||
export class HarnessController {
|
||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
|
||||
const context = readContextFromScope(scopeFromUser(user));
|
||||
const summaries: HarnessSummaryDto[] = [];
|
||||
for (const adapter of this.registry.list()) {
|
||||
summaries.push(toHarnessSummary(await adapter.describe(context)));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
@Get(':harnessId/catalog')
|
||||
async catalog(
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Param('harnessId') harnessId: string,
|
||||
): Promise<HarnessCatalogDto> {
|
||||
const context = readContextFromScope(scopeFromUser(user));
|
||||
let adapter;
|
||||
try {
|
||||
adapter = this.registry.get(harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
// Typed failure — NEVER a fallback catalog for an unknown harness id.
|
||||
throw new HttpException(
|
||||
{ code: error.code, message: error.message, harnessId },
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return toSafeCatalog(await adapter.catalog(context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAuthState,
|
||||
HarnessCapability,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessDescriptor,
|
||||
HarnessInputType,
|
||||
HarnessModelAvailability,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
/**
|
||||
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
|
||||
*
|
||||
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
|
||||
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
|
||||
* any extra property — including smuggled server-authority fields such as
|
||||
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
|
||||
* is rejected with 400. There is deliberately no field through which a caller can
|
||||
* name a scope; scope is derived on the server from the authenticated session.
|
||||
*/
|
||||
export class HarnessSelectionInputDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
harnessId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
providerId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId!: string;
|
||||
}
|
||||
|
||||
/** Browser-safe harness summary — identity and capabilities only. */
|
||||
export interface HarnessSummaryDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly capabilities: readonly HarnessCapability[];
|
||||
}
|
||||
|
||||
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
|
||||
export interface HarnessCatalogEntryDto {
|
||||
readonly harnessId: string;
|
||||
readonly providerId: string;
|
||||
readonly modelId: string;
|
||||
readonly displayName: string;
|
||||
readonly reasoningCapability: boolean;
|
||||
readonly inputTypes: readonly HarnessInputType[];
|
||||
readonly authState: HarnessAuthState;
|
||||
readonly availability: HarnessModelAvailability;
|
||||
}
|
||||
|
||||
/** Browser-safe catalog envelope. */
|
||||
export interface HarnessCatalogDto {
|
||||
readonly harnessId: string;
|
||||
readonly version: string;
|
||||
readonly fingerprint: string;
|
||||
readonly models: readonly HarnessCatalogEntryDto[];
|
||||
}
|
||||
|
||||
/** Response envelope for the caller's current selection (null when unset). */
|
||||
export interface SelectionResponseDto {
|
||||
readonly selection: HarnessSelection | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
|
||||
* session-derived {@link ActorTenantScope}. All authority originates on the server;
|
||||
* nothing here is caller-supplied. A fresh correlation id is minted per call.
|
||||
*/
|
||||
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
|
||||
return {
|
||||
actorId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
seatId: scope.userId,
|
||||
correlationId: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
|
||||
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
|
||||
return {
|
||||
id: descriptor.id,
|
||||
displayName: descriptor.displayName,
|
||||
capabilities: [...descriptor.capabilities],
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
|
||||
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
|
||||
return {
|
||||
harnessId: catalog.harnessId,
|
||||
version: catalog.version,
|
||||
fingerprint: catalog.fingerprint,
|
||||
models: catalog.models.map(toSafeCatalogEntry),
|
||||
};
|
||||
}
|
||||
|
||||
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
|
||||
return {
|
||||
harnessId: entry.harnessId,
|
||||
providerId: entry.providerId,
|
||||
modelId: entry.modelId,
|
||||
displayName: entry.displayName,
|
||||
reasoningCapability: entry.reasoningCapability,
|
||||
inputTypes: [...entry.inputTypes],
|
||||
authState: entry.authState,
|
||||
availability: entry.availability,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HarnessRegistry } from './harness.registry.js';
|
||||
import { HarnessService } from './harness.service.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';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
|
||||
/**
|
||||
* Wires the harness-neutral registry/service (Task Two) together with the
|
||||
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
|
||||
*
|
||||
* The registry is provided empty here; real harness adapters are registered in a
|
||||
* later task. Because the controllers/services resolve their collaborators through
|
||||
* this real module graph, an unresolved provider fails loudly at `app.init()`.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HarnessController, HarnessSelectionController],
|
||||
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, HARNESS_CONVERSATION_SERVICE],
|
||||
})
|
||||
export class HarnessModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistrationError,
|
||||
HarnessRegistry,
|
||||
} from './harness.registry.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
|
||||
describe('HarnessRegistry', () => {
|
||||
it('registers and looks up an adapter by harness id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
|
||||
registry.register(adapter);
|
||||
|
||||
expect(registry.get('fake')).toBe(adapter);
|
||||
expect(registry.has('fake')).toBe(true);
|
||||
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
|
||||
});
|
||||
|
||||
it('rejects a blank adapter id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.register(new FakeHarnessAdapter({ id: ' ' }));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a duplicate adapter id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessRegistrationError);
|
||||
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
|
||||
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
|
||||
// The original registration is untouched.
|
||||
expect(registry.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns adapter_unavailable for an unknown harness id', () => {
|
||||
const registry = new HarnessRegistry();
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
registry.get('missing');
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
|
||||
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
|
||||
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
|
||||
expect(registry.has('missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
HarnessAdapter,
|
||||
HarnessErrorCode,
|
||||
HarnessErrorDto,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
/**
|
||||
* A typed harness operation failure that carries a fully-formed, browser-safe
|
||||
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
|
||||
* tuple — there is no field through which a substituted "effective" selection
|
||||
* could ever be reported.
|
||||
*/
|
||||
export class HarnessOperationError extends Error {
|
||||
readonly code: HarnessErrorCode;
|
||||
readonly dto: HarnessErrorDto;
|
||||
|
||||
constructor(dto: HarnessErrorDto) {
|
||||
super(dto.message);
|
||||
this.name = 'HarnessOperationError';
|
||||
this.code = dto.code;
|
||||
this.dto = dto;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
|
||||
export function operationError(
|
||||
code: HarnessErrorCode,
|
||||
message: string,
|
||||
selection: HarnessSelection,
|
||||
correlationId: string,
|
||||
retryable = false,
|
||||
): HarnessOperationError {
|
||||
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
|
||||
}
|
||||
|
||||
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
|
||||
export class HarnessAdapterUnavailableError extends Error {
|
||||
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
|
||||
|
||||
constructor(readonly harnessId: string) {
|
||||
super(`No harness adapter is registered for id "${harnessId}".`);
|
||||
this.name = 'HarnessAdapterUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
|
||||
|
||||
/** Raised when an adapter cannot be registered (blank or duplicate id). */
|
||||
export class HarnessRegistrationError extends Error {
|
||||
constructor(
|
||||
readonly reason: HarnessRegistrationFailure,
|
||||
readonly harnessId: string,
|
||||
) {
|
||||
super(
|
||||
reason === 'blank_id'
|
||||
? 'A harness adapter id must be a non-empty string.'
|
||||
: `A harness adapter is already registered for id "${harnessId}".`,
|
||||
);
|
||||
this.name = 'HarnessRegistrationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
|
||||
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
|
||||
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessRegistry {
|
||||
private readonly adapters = new Map<string, HarnessAdapter>();
|
||||
|
||||
register(adapter: HarnessAdapter): void {
|
||||
const id = adapter.id;
|
||||
if (typeof id !== 'string' || id.trim().length === 0) {
|
||||
throw new HarnessRegistrationError('blank_id', id ?? '');
|
||||
}
|
||||
if (this.adapters.has(id)) {
|
||||
throw new HarnessRegistrationError('duplicate_id', id);
|
||||
}
|
||||
this.adapters.set(id, adapter);
|
||||
}
|
||||
|
||||
get(harnessId: string): HarnessAdapter {
|
||||
const adapter = this.adapters.get(harnessId);
|
||||
if (!adapter) {
|
||||
throw new HarnessAdapterUnavailableError(harnessId);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
has(harnessId: string): boolean {
|
||||
return this.adapters.has(harnessId);
|
||||
}
|
||||
|
||||
list(): readonly HarnessAdapter[] {
|
||||
return [...this.adapters.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
|
||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
|
||||
import {
|
||||
HarnessScopeViolationError,
|
||||
HarnessService,
|
||||
type TrustedGatewayScope,
|
||||
} from './harness.service.js';
|
||||
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
|
||||
|
||||
const SCOPE: TrustedGatewayScope = {
|
||||
actorId: 'actor-trusted',
|
||||
tenantId: 'tenant-trusted',
|
||||
seatId: 'seat-trusted',
|
||||
correlationId: 'correlation-trusted',
|
||||
};
|
||||
|
||||
const READ_CONTEXT: HarnessActorContext = {
|
||||
actorId: SCOPE.actorId,
|
||||
tenantId: SCOPE.tenantId,
|
||||
seatId: SCOPE.seatId,
|
||||
correlationId: SCOPE.correlationId,
|
||||
};
|
||||
|
||||
function setup(capabilities?: readonly HarnessCapability[]) {
|
||||
const registry = new HarnessRegistry();
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
|
||||
registry.register(adapter);
|
||||
const service = new HarnessService(registry);
|
||||
return { registry, adapter, service };
|
||||
}
|
||||
|
||||
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
|
||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||
const entry = catalog.models.find((model) => model.availability === 'available');
|
||||
if (!entry) {
|
||||
throw new Error('fixture requires an available model');
|
||||
}
|
||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||
}
|
||||
|
||||
describe('HarnessService', () => {
|
||||
it('derives the actor context from trusted scope on create', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const snapshot = await service.createSession(SCOPE, {
|
||||
conversationId: 'conversation-1',
|
||||
selection,
|
||||
});
|
||||
|
||||
expect(snapshot.seatId).toBe(SCOPE.seatId);
|
||||
expect(snapshot.state).toBe('idle');
|
||||
expect(snapshot.selection).toEqual(selection);
|
||||
expect(snapshot.nativeSessionId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects server-authority fields supplied by an external caller', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const hostile = {
|
||||
conversationId: 'conversation-1',
|
||||
selection,
|
||||
seatId: 'attacker-seat',
|
||||
executablePath: '/usr/bin/evil',
|
||||
home: '/home/attacker',
|
||||
cwd: '/tmp/attacker',
|
||||
nativeSessionPath: '/var/native/attacker.jsonl',
|
||||
} as unknown as Parameters<HarnessService['createSession']>[1];
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, hostile);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessScopeViolationError);
|
||||
expect((error as HarnessScopeViolationError).field).toBe('seatId');
|
||||
});
|
||||
|
||||
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
|
||||
const { service } = setup();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'ghost-harness',
|
||||
providerId: 'p',
|
||||
modelId: 'm',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('adapter_unavailable');
|
||||
expect(dto.selection).toEqual(selection);
|
||||
expect(dto.correlationId).toBe(SCOPE.correlationId);
|
||||
});
|
||||
|
||||
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
|
||||
const { service } = setup();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'fake',
|
||||
providerId: 'ghost-provider',
|
||||
modelId: 'ghost-model',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
expect(dto.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('returns model_unavailable without falling back for a known unavailable model', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const catalog = await adapter.catalog(READ_CONTEXT);
|
||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||
expect(unavailable).toBeDefined();
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: unavailable!.harnessId,
|
||||
providerId: unavailable!.providerId,
|
||||
modelId: unavailable!.modelId,
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('model_unavailable');
|
||||
// No substitution: the DTO tuple is exactly what was requested.
|
||||
expect(dto.selection).toEqual(selection);
|
||||
});
|
||||
|
||||
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
|
||||
const { service, adapter } = setup();
|
||||
const selection = await availableSelection(adapter);
|
||||
|
||||
const created = await service.createSession(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
selection,
|
||||
});
|
||||
expect(created.state).toBe('idle');
|
||||
expect(created.processId).toBeTruthy();
|
||||
expect(created.attachedClientIds).toEqual([]);
|
||||
|
||||
const resumed = await service.resumeSession(SCOPE, {
|
||||
conversationId: 'conversation-resume',
|
||||
nativeSessionId: 'native-preexisting-123',
|
||||
selection,
|
||||
});
|
||||
// Resume binds the supplied native session; create mints a fresh one.
|
||||
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
|
||||
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
|
||||
|
||||
await service.attach(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
clientId: 'browser-1',
|
||||
});
|
||||
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
|
||||
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
|
||||
|
||||
const afterDetach = await service.detach(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
clientId: 'browser-1',
|
||||
});
|
||||
// Detach removes the browser attachment only; the process stays alive.
|
||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||
expect(afterDetach.state).toBe('idle');
|
||||
expect(afterDetach.processId).toBeTruthy();
|
||||
|
||||
const afterEvict = await service.evict(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
reason: 'idle_timeout',
|
||||
});
|
||||
// Evict stops the process but retains the resumable native session.
|
||||
expect(afterEvict.state).toBe('evicted');
|
||||
expect(afterEvict.processId).toBeUndefined();
|
||||
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
|
||||
|
||||
const afterEnd = await service.end(SCOPE, {
|
||||
conversationId: 'conversation-create',
|
||||
reason: 'session_ended',
|
||||
});
|
||||
// End destructively terminates the native session.
|
||||
expect(afterEnd.state).toBe('ended');
|
||||
});
|
||||
|
||||
it('fails typed when an unsupported capability is exercised', async () => {
|
||||
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
|
||||
(capability) => capability !== 'extensionUi',
|
||||
);
|
||||
const { service, adapter } = setup(withoutExtensionUi);
|
||||
const selection = await availableSelection(adapter);
|
||||
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await service.respondInteraction(SCOPE, {
|
||||
conversationId: 'conversation-1',
|
||||
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAdapter,
|
||||
HarnessCatalog,
|
||||
HarnessCloseReason,
|
||||
HarnessInteractionResponse,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
} from '@mosaicstack/types';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistry,
|
||||
operationError,
|
||||
} from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
|
||||
/**
|
||||
* Trusted, server-derived authority. In production this is produced by the
|
||||
* Gateway from the authenticated session — never from a browser/caller DTO.
|
||||
*/
|
||||
export interface TrustedGatewayScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly seatId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/** Server-authority fields that must never arrive from an external request DTO. */
|
||||
const FORBIDDEN_REQUEST_FIELDS = [
|
||||
'actorId',
|
||||
'tenantId',
|
||||
'correlationId',
|
||||
'seatId',
|
||||
'seat',
|
||||
'executable',
|
||||
'executablePath',
|
||||
'home',
|
||||
'homeDir',
|
||||
'cwd',
|
||||
'workingDir',
|
||||
'workingDirectory',
|
||||
'nativeSessionPath',
|
||||
'sessionPath',
|
||||
] as const;
|
||||
|
||||
/** Raised when an external request DTO smuggles a server-authority field. */
|
||||
export class HarnessScopeViolationError extends Error {
|
||||
constructor(readonly field: string) {
|
||||
super(`External request supplied server-authority field "${field}".`);
|
||||
this.name = 'HarnessScopeViolationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateHarnessSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ResumeHarnessSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly nativeSessionId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface AttachClientRequest {
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface DetachClientRequest {
|
||||
readonly conversationId: string;
|
||||
readonly clientId: string;
|
||||
}
|
||||
|
||||
export interface EvictSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly reason: HarnessCloseReason;
|
||||
}
|
||||
|
||||
export interface EndSessionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly reason: HarnessCloseReason;
|
||||
}
|
||||
|
||||
export interface RespondInteractionRequest {
|
||||
readonly conversationId: string;
|
||||
readonly response: HarnessInteractionResponse;
|
||||
}
|
||||
|
||||
interface ActiveSession {
|
||||
readonly harnessId: string;
|
||||
readonly handle: HarnessSessionHandle;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
||||
* from trusted Gateway scope, validates the selected provider/model tuple with
|
||||
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
||||
* lifecycle operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessService {
|
||||
private readonly sessions = new Map<string, ActiveSession>();
|
||||
|
||||
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
||||
|
||||
async createSession(
|
||||
scope: TrustedGatewayScope,
|
||||
request: CreateHarnessSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const { conversationId, selection } = request;
|
||||
const adapter = this.resolveAdapter(scope, selection);
|
||||
const context = deriveActorContext(scope);
|
||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||
|
||||
const handle = await adapter.create({ context, conversationId, selection });
|
||||
this.sessions.set(conversationId, {
|
||||
harnessId: selection.harnessId,
|
||||
handle,
|
||||
correlationId: scope.correlationId,
|
||||
});
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
scope: TrustedGatewayScope,
|
||||
request: ResumeHarnessSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const { conversationId, nativeSessionId, selection } = request;
|
||||
const adapter = this.resolveAdapter(scope, selection);
|
||||
const context = deriveActorContext(scope);
|
||||
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
||||
|
||||
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
||||
this.sessions.set(conversationId, {
|
||||
harnessId: selection.harnessId,
|
||||
handle,
|
||||
correlationId: scope.correlationId,
|
||||
});
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async attach(
|
||||
scope: TrustedGatewayScope,
|
||||
request: AttachClientRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.attach({ clientId: request.clientId });
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async detach(
|
||||
scope: TrustedGatewayScope,
|
||||
request: DetachClientRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.detach(request.clientId);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async evict(
|
||||
scope: TrustedGatewayScope,
|
||||
request: EvictSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.evictProcess(request.reason);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
async end(
|
||||
scope: TrustedGatewayScope,
|
||||
request: EndSessionRequest,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.endSession(request.reason);
|
||||
const snapshot = await handle.snapshot();
|
||||
this.sessions.delete(request.conversationId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async respondInteraction(
|
||||
scope: TrustedGatewayScope,
|
||||
request: RespondInteractionRequest,
|
||||
): Promise<void> {
|
||||
assertTrustedRequest(request);
|
||||
const handle = this.requireHandle(scope, request.conversationId);
|
||||
await handle.respondInteraction(request.response);
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
scope: TrustedGatewayScope,
|
||||
conversationId: string,
|
||||
): Promise<HarnessSessionSnapshot> {
|
||||
const handle = this.requireHandle(scope, conversationId);
|
||||
return handle.snapshot();
|
||||
}
|
||||
|
||||
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
||||
try {
|
||||
return this.registry.get(selection.harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertSelectionAvailable(
|
||||
scope: TrustedGatewayScope,
|
||||
catalogPromise: Promise<HarnessCatalog>,
|
||||
selection: HarnessSelection,
|
||||
): Promise<void> {
|
||||
const catalog = await catalogPromise;
|
||||
const entry = catalog.models.find(
|
||||
(candidate) =>
|
||||
candidate.harnessId === selection.harnessId &&
|
||||
candidate.providerId === selection.providerId &&
|
||||
candidate.modelId === selection.modelId,
|
||||
);
|
||||
if (!entry) {
|
||||
// No first-row fallback: reject the requested tuple unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
scope.correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
scope.correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
||||
const active = this.sessions.get(conversationId);
|
||||
if (!active) {
|
||||
throw operationError(
|
||||
'session_not_found',
|
||||
`No active harness session for conversation "${conversationId}".`,
|
||||
{ harnessId: '', providerId: '', modelId: '' },
|
||||
scope.correlationId,
|
||||
);
|
||||
}
|
||||
return active.handle;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
||||
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
||||
return {
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
seatId: scope.seatId,
|
||||
correlationId: scope.correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reject any request object that carries a server-authority field. */
|
||||
function assertTrustedRequest(request: object): void {
|
||||
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
||||
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
||||
throw new HarnessScopeViolationError(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the typed operation error so callers importing from the service
|
||||
// have the discriminated failure type without reaching into the registry.
|
||||
export { HarnessOperationError } from './harness.registry.js';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Nest dependency-injection tokens for the harness-neutral registry and service.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
|
||||
import { HarnessOperationError } from '../harness.registry.js';
|
||||
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
|
||||
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
|
||||
|
||||
const CONTEXT: HarnessActorContext = {
|
||||
actorId: 'actor-1',
|
||||
tenantId: 'tenant-1',
|
||||
seatId: 'seat-1',
|
||||
correlationId: 'correlation-1',
|
||||
};
|
||||
|
||||
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
|
||||
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
|
||||
|
||||
describe('FakeHarnessAdapter no-substitution', () => {
|
||||
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const firstRow = catalog.models[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('fixture requires a catalog model');
|
||||
}
|
||||
const available = catalog.models.find(
|
||||
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
|
||||
);
|
||||
expect(available).toBeDefined();
|
||||
const selected: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-1',
|
||||
selection: selected,
|
||||
});
|
||||
|
||||
const bogus: HarnessSelection = {
|
||||
harnessId: 'fake',
|
||||
providerId: 'ghost-provider',
|
||||
modelId: 'ghost-model',
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(bogus);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
// The DTO echoes the exact requested tuple, unchanged.
|
||||
expect(dto.selection).toEqual(bogus);
|
||||
// No substitution to the first catalog row.
|
||||
expect(dto.selection).not.toEqual({
|
||||
harnessId: firstRow.harnessId,
|
||||
providerId: firstRow.providerId,
|
||||
modelId: firstRow.modelId,
|
||||
});
|
||||
// The active selection is untouched by the rejected request.
|
||||
expect((await handle.snapshot()).selection).toEqual(selected);
|
||||
});
|
||||
|
||||
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
|
||||
const adapter = new FakeHarnessAdapter({ id: 'fake' });
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
|
||||
const available = catalog.models.find((entry) => entry.availability === 'available');
|
||||
expect(unavailable).toBeDefined();
|
||||
expect(available).toBeDefined();
|
||||
|
||||
const startingSelection: HarnessSelection = {
|
||||
harnessId: available!.harnessId,
|
||||
providerId: available!.providerId,
|
||||
modelId: available!.modelId,
|
||||
};
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conversation-2',
|
||||
selection: startingSelection,
|
||||
});
|
||||
|
||||
const requested: HarnessSelection = {
|
||||
harnessId: unavailable!.harnessId,
|
||||
providerId: unavailable!.providerId,
|
||||
modelId: unavailable!.modelId,
|
||||
};
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await handle.setModel(requested);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('model_unavailable');
|
||||
expect(dto.selection).toEqual(requested);
|
||||
expect((await handle.snapshot()).selection).toEqual(startingSelection);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
AttachClient,
|
||||
CreateHarnessSession,
|
||||
HarnessAdapter,
|
||||
HarnessActorContext,
|
||||
HarnessCapability,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessCloseReason,
|
||||
HarnessDescriptor,
|
||||
HarnessEvent,
|
||||
HarnessInteractionResponse,
|
||||
HarnessPrompt,
|
||||
HarnessPromptReceipt,
|
||||
HarnessSelection,
|
||||
HarnessSessionHandle,
|
||||
HarnessSessionSnapshot,
|
||||
HarnessSessionState,
|
||||
ResumeHarnessSession,
|
||||
} from '@mosaicstack/types';
|
||||
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
|
||||
import { operationError } from '../harness.registry.js';
|
||||
|
||||
export interface FakeHarnessAdapterOptions {
|
||||
readonly id: string;
|
||||
readonly capabilities?: readonly HarnessCapability[];
|
||||
readonly catalog?: readonly HarnessCatalogEntry[];
|
||||
}
|
||||
|
||||
const FAKE_PROVIDER = 'fake-openai';
|
||||
|
||||
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
|
||||
return [
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-mini',
|
||||
displayName: 'Fake Mini',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-pro',
|
||||
displayName: 'Fake Pro',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text', 'image'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId,
|
||||
providerId: FAKE_PROVIDER,
|
||||
modelId: 'fake-legacy',
|
||||
displayName: 'Fake Legacy',
|
||||
reasoningCapability: false,
|
||||
inputTypes: ['text'],
|
||||
authState: 'unavailable',
|
||||
availability: 'unavailable',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
|
||||
return (
|
||||
entry.harnessId === selection.harnessId &&
|
||||
entry.providerId === selection.providerId &&
|
||||
entry.modelId === selection.modelId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory harness session handle used by the fake adapter and by the shared
|
||||
* conformance suite. It enforces the two invariants the real adapters must also
|
||||
* honor: model selection is validated against the catalog and is NEVER
|
||||
* substituted, and unsupported capabilities fail with a typed error.
|
||||
*/
|
||||
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
|
||||
private state: HarnessSessionState = 'idle';
|
||||
private processId: string | undefined;
|
||||
private readonly attachedClientIds = new Set<string>();
|
||||
private readonly listeners = new Set<(event: HarnessEvent) => void>();
|
||||
|
||||
constructor(
|
||||
private readonly conversationId: string,
|
||||
private readonly nativeSessionId: string,
|
||||
private readonly seatId: string,
|
||||
private selection: HarnessSelection,
|
||||
private readonly correlationId: string,
|
||||
private readonly capabilities: readonly HarnessCapability[],
|
||||
private readonly catalog: readonly HarnessCatalogEntry[],
|
||||
) {
|
||||
this.processId = `process-${nativeSessionId}`;
|
||||
}
|
||||
|
||||
async snapshot(): Promise<HarnessSessionSnapshot> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
nativeSessionId: this.nativeSessionId,
|
||||
processId: this.processId,
|
||||
seatId: this.seatId,
|
||||
selection: this.selection,
|
||||
state: this.state,
|
||||
attachedClientIds: [...this.attachedClientIds],
|
||||
};
|
||||
}
|
||||
|
||||
async attach(input: AttachClient): Promise<void> {
|
||||
this.attachedClientIds.add(input.clientId);
|
||||
}
|
||||
|
||||
async detach(clientId: string): Promise<void> {
|
||||
// Removes the browser attachment only; the process and native session persist.
|
||||
this.attachedClientIds.delete(clientId);
|
||||
}
|
||||
|
||||
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
|
||||
return {
|
||||
conversationId: this.conversationId,
|
||||
turnId: input.turnId,
|
||||
correlationId: input.correlationId,
|
||||
state: 'accepted',
|
||||
selection: this.selection,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
|
||||
const entry = this.catalog.find((candidate) => matches(candidate, selection));
|
||||
if (!entry) {
|
||||
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
this.correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
this.selection = selection;
|
||||
return this.selection;
|
||||
}
|
||||
|
||||
async abort(_turnId: string): Promise<void> {
|
||||
// No active turn machinery in the fake; abort is a no-op acknowledgement.
|
||||
}
|
||||
|
||||
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
|
||||
if (!this.capabilities.includes('extensionUi')) {
|
||||
throw operationError(
|
||||
'interaction_unsupported',
|
||||
'This harness does not support interactive responses.',
|
||||
this.selection,
|
||||
this.correlationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
events(listener: (event: HarnessEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Stop the process but keep the resumable native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'evicted';
|
||||
}
|
||||
|
||||
async endSession(_reason: HarnessCloseReason): Promise<void> {
|
||||
// Destructively end the native session.
|
||||
this.processId = undefined;
|
||||
this.state = 'ended';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
|
||||
* native session id on `create` and binds the supplied one on `resume`, so the
|
||||
* two paths are observably distinct.
|
||||
*/
|
||||
export class FakeHarnessAdapter implements HarnessAdapter {
|
||||
readonly id: string;
|
||||
private readonly capabilities: readonly HarnessCapability[];
|
||||
private readonly catalogEntries: readonly HarnessCatalogEntry[];
|
||||
private createdCount = 0;
|
||||
|
||||
constructor(options: FakeHarnessAdapterOptions) {
|
||||
this.id = options.id;
|
||||
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
|
||||
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
|
||||
}
|
||||
|
||||
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
|
||||
return {
|
||||
id: this.id,
|
||||
displayName: `Fake harness (${this.id})`,
|
||||
capabilities: this.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
|
||||
return {
|
||||
harnessId: this.id,
|
||||
version: '1.0.0',
|
||||
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
|
||||
models: this.catalogEntries,
|
||||
};
|
||||
}
|
||||
|
||||
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
|
||||
this.createdCount += 1;
|
||||
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
|
||||
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
|
||||
return new FakeHarnessSessionHandle(
|
||||
input.conversationId,
|
||||
input.nativeSessionId,
|
||||
input.context.seatId,
|
||||
input.selection,
|
||||
input.context.correlationId,
|
||||
this.capabilities,
|
||||
this.catalogEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
HarnessActorContext,
|
||||
HarnessAdapter,
|
||||
HarnessCatalogEntry,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
import { HarnessOperationError } from '../harness.registry.js';
|
||||
|
||||
const CONTEXT: HarnessActorContext = {
|
||||
actorId: 'contract-actor',
|
||||
tenantId: 'contract-tenant',
|
||||
seatId: 'contract-seat',
|
||||
correlationId: 'contract-correlation',
|
||||
};
|
||||
|
||||
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
|
||||
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
|
||||
}
|
||||
|
||||
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
|
||||
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
|
||||
if (!entry) {
|
||||
throw new Error('contract fixture requires at least one catalog model');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await run();
|
||||
return undefined;
|
||||
} catch (caught) {
|
||||
return caught;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
|
||||
* runs it against the fake adapter; Task 13 re-runs the identical suite against
|
||||
* the native Pi adapter so both share one behavioral contract.
|
||||
*/
|
||||
export function runHarnessAdapterContract(
|
||||
label: string,
|
||||
createAdapter: () => HarnessAdapter,
|
||||
): void {
|
||||
describe(`harness adapter contract: ${label}`, () => {
|
||||
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
|
||||
const created = await (
|
||||
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
|
||||
).snapshot();
|
||||
const resumed = await (
|
||||
await adapter.resume({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-resume',
|
||||
nativeSessionId: 'native-supplied-1',
|
||||
selection,
|
||||
})
|
||||
).snapshot();
|
||||
|
||||
expect(created.nativeSessionId).toBeTruthy();
|
||||
expect(resumed.nativeSessionId).toBe('native-supplied-1');
|
||||
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
|
||||
expect(created.seatId).toBe(CONTEXT.seatId);
|
||||
});
|
||||
|
||||
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-lifecycle',
|
||||
selection,
|
||||
});
|
||||
|
||||
await handle.attach({ clientId: 'browser-1' });
|
||||
await handle.detach('browser-1');
|
||||
const afterDetach = await handle.snapshot();
|
||||
expect(afterDetach.attachedClientIds).toEqual([]);
|
||||
expect(afterDetach.state).not.toBe('evicted');
|
||||
expect(afterDetach.state).not.toBe('ended');
|
||||
|
||||
await handle.evictProcess('idle_timeout');
|
||||
const afterEvict = await handle.snapshot();
|
||||
expect(afterEvict.state).toBe('evicted');
|
||||
// The native session survives eviction (resumable); the process does not.
|
||||
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
|
||||
expect(afterEvict.processId).toBeUndefined();
|
||||
|
||||
await handle.endSession('session_ended');
|
||||
const afterEnd = await handle.snapshot();
|
||||
expect(afterEnd.state).toBe('ended');
|
||||
// End is not an alias of evict.
|
||||
expect(afterEnd.state).not.toBe(afterEvict.state);
|
||||
});
|
||||
|
||||
it('never substitutes the first catalog row for an unknown selection', async () => {
|
||||
const adapter = createAdapter();
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const firstRow = catalog.models[0];
|
||||
if (!firstRow) {
|
||||
throw new Error('contract fixture requires a catalog model');
|
||||
}
|
||||
const start = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-nosub',
|
||||
selection: start,
|
||||
});
|
||||
|
||||
const bogus: HarnessSelection = {
|
||||
harnessId: adapter.id,
|
||||
providerId: 'contract-ghost-provider',
|
||||
modelId: 'contract-ghost-model',
|
||||
};
|
||||
const error = await captureError(() => handle.setModel(bogus));
|
||||
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
const dto = (error as HarnessOperationError).dto;
|
||||
expect(dto.code).toBe('selection_invalid');
|
||||
expect(dto.selection).toEqual(bogus);
|
||||
expect(dto.selection).not.toEqual(toSelection(firstRow));
|
||||
expect((await handle.snapshot()).selection).toEqual(start);
|
||||
});
|
||||
|
||||
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
|
||||
const adapter = createAdapter();
|
||||
const descriptor = await adapter.describe(CONTEXT);
|
||||
const catalog = await adapter.catalog(CONTEXT);
|
||||
const selection = toSelection(pickAvailable(catalog.models));
|
||||
const handle = await adapter.create({
|
||||
context: CONTEXT,
|
||||
conversationId: 'conv-interaction',
|
||||
selection,
|
||||
});
|
||||
|
||||
const response = {
|
||||
requestId: 'interaction-1',
|
||||
type: 'confirm',
|
||||
accepted: true,
|
||||
} as const;
|
||||
|
||||
if (descriptor.capabilities.includes('extensionUi')) {
|
||||
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
|
||||
} else {
|
||||
const error = await captureError(() => handle.respondInteraction(response));
|
||||
expect(error).toBeInstanceOf(HarnessOperationError);
|
||||
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'reflect-metadata';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import * as nodeOs from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import * as nodeUrl from 'node:url';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import type * as MosaicStorage from '@mosaicstack/storage';
|
||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
||||
|
||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in snapshot)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
||||
const relativePath = relative(tempRoot, path);
|
||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
||||
expectPathUnderTempRoot(path, tempRoot);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, contents, 'utf8');
|
||||
}
|
||||
|
||||
interface BootstrapPreflightResult {
|
||||
capturedConfig: MosaicConfig | undefined;
|
||||
}
|
||||
|
||||
async function runBootstrapPreflight(
|
||||
anchoredConfigContents: string,
|
||||
ambientConfigContents: string,
|
||||
): Promise<BootstrapPreflightResult> {
|
||||
const originalEnv = snapshotProcessEnv();
|
||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let capturedConfig: MosaicConfig | undefined;
|
||||
|
||||
try {
|
||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
||||
const homePath = join(tempRoot, 'home');
|
||||
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
||||
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
||||
|
||||
await mkdir(anchor, { recursive: true });
|
||||
await mkdir(cwdPath, { recursive: true });
|
||||
|
||||
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
||||
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
||||
|
||||
process.env['HOME'] = homePath;
|
||||
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
||||
delete process.env['DATABASE_URL'];
|
||||
delete process.env['VALKEY_URL'];
|
||||
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
||||
const exitMock = vi.fn<typeof process.exit>();
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
||||
vi.doMock('node:url', () => ({
|
||||
...nodeUrl,
|
||||
fileURLToPath: (url: string | URL): string => {
|
||||
const actualPath = nodeUrl.fileURLToPath(url);
|
||||
if (
|
||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
||||
) {
|
||||
return join(anchor, 'env.ts');
|
||||
}
|
||||
return actualPath;
|
||||
},
|
||||
}));
|
||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
||||
vi.doMock('./tracing.js', () => ({}));
|
||||
|
||||
const preflightSentinel = new Error('preflight-capture-sentinel');
|
||||
vi.doMock('@mosaicstack/storage', async () => {
|
||||
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
||||
return {
|
||||
...actual,
|
||||
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
||||
capturedConfig = config;
|
||||
throw preflightSentinel;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await import('./main.js');
|
||||
await vi.waitFor((): void => {
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
return { capturedConfig };
|
||||
} finally {
|
||||
cwdSpy?.mockRestore();
|
||||
exitSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
vi.doUnmock('@mosaicstack/storage');
|
||||
vi.doUnmock('./tracing.js');
|
||||
vi.doUnmock('node:url');
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
restoreProcessEnv(originalEnv);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('main bootstrap preflight config anchoring', (): void => {
|
||||
it(
|
||||
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
||||
async (): Promise<void> => {
|
||||
const anchoredConfig = JSON.stringify({
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
});
|
||||
const ambientConfig = JSON.stringify({
|
||||
tier: 'federated',
|
||||
storage: {
|
||||
type: 'postgres',
|
||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
||||
enableVector: true,
|
||||
},
|
||||
queue: { type: 'bullmq' },
|
||||
memory: { type: 'pgvector' },
|
||||
});
|
||||
|
||||
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
||||
|
||||
expect(capturedConfig?.tier).toBe('local');
|
||||
expect(capturedConfig?.storage).not.toEqual(
|
||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
||||
);
|
||||
},
|
||||
MODULE_IMPORT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
@@ -1,18 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { config } from 'dotenv';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// Load .env from daemon config dir (global install / daemon mode).
|
||||
// Loaded first so monorepo .env can override for local dev.
|
||||
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||
|
||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||
config({ path: resolve(process.cwd(), '../../.env') });
|
||||
config(); // Also load apps/gateway/.env if present (overrides)
|
||||
|
||||
import './env.js';
|
||||
import './tracing.js';
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
@@ -26,6 +13,7 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
@@ -37,7 +25,7 @@ async function bootstrap(): Promise<void> {
|
||||
// Pre-flight: assert all external services required by the configured tier
|
||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||
// immediately with actionable remediation hints.
|
||||
const mosaicConfig = loadConfig();
|
||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
||||
try {
|
||||
await detectAndAssertTier(mosaicConfig);
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,10 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
||||
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
|
||||
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-service-token';
|
||||
@@ -25,6 +29,7 @@ const ENV_KEYS = [
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_CONFIG_ID',
|
||||
'CHAT_HARNESS_RUNTIME',
|
||||
] as const;
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
@@ -150,6 +155,57 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
|
||||
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
|
||||
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
|
||||
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
|
||||
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
|
||||
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
|
||||
*
|
||||
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
|
||||
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
|
||||
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
|
||||
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
|
||||
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
|
||||
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
|
||||
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
|
||||
*
|
||||
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
|
||||
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
|
||||
* and never from a browser-emittable socket event (the browser-forgery refusal test).
|
||||
*/
|
||||
function readyPiRpcRegistry(): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
|
||||
// router resolve `active` = harness instead of failing closed at init, so these tests model the
|
||||
// real hostile condition — the harness runtime IS live — rather than a degraded router.
|
||||
registry.register({ id: 'pi' } as never);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function piRpcRouterFronting(
|
||||
agentService: unknown,
|
||||
harnessConversations: { append: ReturnType<typeof vi.fn> },
|
||||
): ChatRuntimeRouter {
|
||||
const routerConversationServiceTripwire = {
|
||||
append: () => {
|
||||
throw new Error('router conversation service must not be resolved on the Discord path');
|
||||
},
|
||||
};
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(harnessConversations as never);
|
||||
const router = new ChatRuntimeRouter(
|
||||
readyPiRpcRegistry(),
|
||||
routerConversationServiceTripwire as never,
|
||||
embedded,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('Discord ingress security', () => {
|
||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||
const [binding] = parseDiscordInteractionBindings(
|
||||
@@ -433,6 +489,7 @@ describe('Discord ingress security', () => {
|
||||
|
||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
@@ -489,8 +546,9 @@ describe('Discord ingress security', () => {
|
||||
},
|
||||
};
|
||||
const routingEngine = { resolve: vi.fn() };
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
@@ -531,6 +589,575 @@ describe('Discord ingress security', () => {
|
||||
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
||||
);
|
||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
|
||||
// never touch it — the create path stays on the embedded runtime.
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
agentName: 'Nova',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const createSession = vi.fn().mockResolvedValue(session);
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
const brain = {
|
||||
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-replay',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const ackCount = (): number =>
|
||||
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||
|
||||
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
|
||||
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
});
|
||||
|
||||
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
|
||||
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
|
||||
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
// The harness runtime is never touched on either delivery.
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
agentName: 'Nova',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const createSession = vi.fn().mockResolvedValue(session);
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
const brain = {
|
||||
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-claim-ordering',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const ackCount = (): number =>
|
||||
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||
|
||||
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
|
||||
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
});
|
||||
|
||||
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
|
||||
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
|
||||
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
|
||||
// replay claim for `discord-order-001` even though nothing dispatched.
|
||||
delete process.env['DISCORD_SERVICE_USER_ID'];
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(0);
|
||||
expect(prompt).toHaveBeenCalledTimes(0);
|
||||
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||
expect(ackCount()).toBe(0);
|
||||
|
||||
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
|
||||
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
|
||||
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
|
||||
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
|
||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
|
||||
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
|
||||
// blocks it, so every effect remains at exactly one.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
agentName: 'Nova',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const createSession = vi.fn().mockResolvedValue(session);
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
// The durable agent record does not reconcile on the first delivery (its name no longer matches
|
||||
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
|
||||
const findAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
|
||||
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||
const brain = {
|
||||
agents: { findById: findAgent },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-reconcile',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const ackCount = (): number =>
|
||||
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||
|
||||
const envelope = ingressEnvelope(
|
||||
'verified once with stale agent record',
|
||||
'discord-reconcile-001',
|
||||
{
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
},
|
||||
);
|
||||
|
||||
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
|
||||
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(0);
|
||||
expect(prompt).toHaveBeenCalledTimes(0);
|
||||
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||
expect(ackCount()).toBe(0);
|
||||
|
||||
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
|
||||
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
|
||||
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
|
||||
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
|
||||
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
|
||||
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
|
||||
const foreignIdentitySession = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-orion',
|
||||
agentName: 'Orion',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
const brain = {
|
||||
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-identity-swap',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
}),
|
||||
);
|
||||
|
||||
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||
);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
agentName: 'Nova',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const createSession = vi.fn().mockResolvedValue(session);
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
|
||||
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
|
||||
// then admit the corrected record whose id matches the binding.
|
||||
const findAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
|
||||
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
|
||||
const brain = {
|
||||
agents: { findById: findAgent },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-reconcile-id',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const ackCount = (): number =>
|
||||
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
||||
|
||||
const envelope = ingressEnvelope(
|
||||
'verified once with aliased agent id',
|
||||
'discord-reconcile-id-001',
|
||||
{
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
},
|
||||
);
|
||||
|
||||
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
|
||||
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(0);
|
||||
expect(prompt).toHaveBeenCalledTimes(0);
|
||||
expect(addMessage).toHaveBeenCalledTimes(0);
|
||||
expect(ackCount()).toBe(0);
|
||||
|
||||
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
|
||||
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
|
||||
// (3) A genuine duplicate after the committed turn stays fail-closed.
|
||||
await gateway.handleMessage(client as never, envelope);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ackCount()).toBe(1);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
|
||||
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
|
||||
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
|
||||
// another agent's verified label. (The existing reuse test covers the getSession path; this
|
||||
// covers the createSession path scrappy flagged as unvalidated.)
|
||||
const mintedForeignSession = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
agentConfigId: 'agent-config-orion',
|
||||
agentName: 'Orion',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt,
|
||||
};
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
const brain = {
|
||||
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-postcreate-mismatch',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
}),
|
||||
);
|
||||
|
||||
// The freshly minted session failed the post-create identity recheck: refused with a typed
|
||||
// error, no prompt, no persist, no ack.
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
|
||||
);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||
@@ -593,11 +1220,16 @@ describe('Discord ingress security', () => {
|
||||
|
||||
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
|
||||
const session = {
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
// The reused embedded session carries the SAME reconciled identity as the verified binding,
|
||||
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
|
||||
agentConfigId: 'agent-config-nova',
|
||||
agentName: 'Nova',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
@@ -611,6 +1243,7 @@ describe('Discord ingress security', () => {
|
||||
prompt,
|
||||
};
|
||||
const brain = {
|
||||
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -618,8 +1251,9 @@ describe('Discord ingress security', () => {
|
||||
addMessage,
|
||||
},
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
@@ -667,6 +1301,66 @@ describe('Discord ingress security', () => {
|
||||
}),
|
||||
'discord-service',
|
||||
);
|
||||
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
|
||||
// the router resolved as `active` is never reached.
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
|
||||
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
|
||||
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
|
||||
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
|
||||
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
|
||||
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
|
||||
// the harness. There is no dedicated socket event for verified ingress — the only ingress
|
||||
// surface is the generic `message` handler, and a non-service client is refused there.
|
||||
//
|
||||
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
|
||||
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
|
||||
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
|
||||
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const routingEngine = { resolve: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
piRpcRouterFronting(agentService, harnessConversations) as never,
|
||||
{} as never,
|
||||
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
routingEngine as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'browser-forging-discord',
|
||||
data: { discordService: false },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
}),
|
||||
);
|
||||
|
||||
const refusal = client.emit.mock.calls.find(
|
||||
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
|
||||
);
|
||||
expect(refusal).toBeDefined();
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||
|
||||
@@ -143,6 +143,12 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const executor = new CommandExecutorService(
|
||||
registry as never,
|
||||
mockAgentService as never,
|
||||
@@ -152,7 +158,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
mockBrain as never,
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
mockMcpClient as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3101",
|
||||
"dev:vite": "vite",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "playwright test",
|
||||
"start": "next start"
|
||||
"start": "next start -p 3101"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/design-tokens": "workspace:^",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
import type {
|
||||
HarnessAuthState,
|
||||
HarnessModelAvailability,
|
||||
HarnessSelection,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
// The exact harness/provider/model tuple and its closed enum companions are the
|
||||
// shared domain types — re-exported here so web consumers (and the runtime
|
||||
// guards) import one shape, never a divergent local redefinition.
|
||||
export type { HarnessSelection, HarnessAuthState, HarnessModelAvailability };
|
||||
|
||||
/** Harness summary row from `GET /api/harnesses` (the `HarnessSummaryDto`). The
|
||||
* harness id is kept distinct from any provider id — they are never merged. */
|
||||
export interface HarnessSummary {
|
||||
id: string;
|
||||
displayName: string;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
/** One selectable model in a harness catalog. Extends the `{harnessId,
|
||||
* providerId, modelId}` tuple with the display/availability metadata the UI
|
||||
* needs; `inputTypes` is kept as a plain `string[]` on the client boundary
|
||||
* because it arrives from untrusted JSON and is only ever displayed. */
|
||||
export interface HarnessCatalogEntry extends HarnessSelection {
|
||||
displayName: string;
|
||||
reasoningCapability: boolean;
|
||||
inputTypes: string[];
|
||||
authState: HarnessAuthState;
|
||||
availability: HarnessModelAvailability;
|
||||
}
|
||||
|
||||
/** Harness-scoped catalog from `GET /api/harnesses/:harnessId/catalog`. */
|
||||
export interface HarnessCatalog {
|
||||
harnessId: string;
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
models: HarnessCatalogEntry[];
|
||||
}
|
||||
|
||||
/** Conversation returned by the gateway API. */
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
|
||||
+19
-3
@@ -5,6 +5,14 @@ import { RegisterPage } from '@/spa/pages/register';
|
||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||
import { ProjectsPage } from '@/spa/pages/projects';
|
||||
import {
|
||||
ProjectDetailRouteErrorBoundary,
|
||||
ProjectsRouteErrorBoundary,
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
|
||||
@@ -37,9 +45,17 @@ export const routes: RouteObject[] = [
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||
{
|
||||
path: '/projects',
|
||||
element: <ProjectsPage />,
|
||||
errorElement: <ProjectsRouteErrorBoundary />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
element: <ProjectDetailPage />,
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchCatalog,
|
||||
fetchHarnesses,
|
||||
fetchPersistedSelection,
|
||||
persistSelection,
|
||||
} from './chat-api';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** Every URL the client actually requested, across all calls. */
|
||||
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
}
|
||||
|
||||
describe('chat-api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json([
|
||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||
]),
|
||||
);
|
||||
|
||||
const harnesses = await fetchHarnesses();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
|
||||
expect(harnesses).toEqual([
|
||||
{ id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
|
||||
{ id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'abc123',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
displayName: 'GPT-5',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCatalog('pi');
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error('expected ok catalog');
|
||||
expect(result.catalog.harnessId).toBe('pi');
|
||||
expect(result.catalog.models).toHaveLength(1);
|
||||
expect(result.catalog.models[0]).toMatchObject({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
availability: 'available',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json(
|
||||
{
|
||||
code: 'adapter_unavailable',
|
||||
message: 'raw gateway detail that must not leak verbatim',
|
||||
harnessId: 'attacker-echo',
|
||||
extra: { hostile: 'blob' },
|
||||
},
|
||||
404,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchCatalog('ghost');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error('expected unavailable result');
|
||||
expect(result.code).toBe('catalog_unavailable');
|
||||
// harnessId comes from the request, never the (untrusted) response body.
|
||||
expect(result.harnessId).toBe('ghost');
|
||||
expect(typeof result.message).toBe('string');
|
||||
// The raw response body is never rendered/returned verbatim.
|
||||
expect(JSON.stringify(result)).not.toContain('hostile');
|
||||
expect(JSON.stringify(result)).not.toContain('attacker-echo');
|
||||
});
|
||||
|
||||
it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
await expect(fetchPersistedSelection()).resolves.toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({ selection: null }));
|
||||
await expect(fetchPersistedSelection()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
|
||||
const result = await persistSelection({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
|
||||
const init = call?.[1] as RequestInit;
|
||||
expect(String(init.method).toUpperCase()).toBe('PUT');
|
||||
// The body is exactly the structured tuple — harness/provider/model kept distinct.
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(
|
||||
json(
|
||||
{
|
||||
code: 'model_unavailable',
|
||||
message: 'raw detail that must not leak',
|
||||
selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
|
||||
},
|
||||
422,
|
||||
),
|
||||
);
|
||||
|
||||
const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
|
||||
const result = await persistSelection(requested);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error('expected failed persist');
|
||||
expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
|
||||
// The requested tuple is preserved unchanged — not replaced by the body's echo.
|
||||
expect(result.requested).toEqual(requested);
|
||||
expect(JSON.stringify(result)).not.toContain('raw detail');
|
||||
});
|
||||
|
||||
it('never requests any /api/providers* endpoint', async () => {
|
||||
const fetchMock = stubFetch();
|
||||
fetchMock.mockResolvedValue(json([]));
|
||||
await fetchHarnesses();
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
|
||||
);
|
||||
await fetchCatalog('pi');
|
||||
fetchMock.mockResolvedValue(json({ selection: null }));
|
||||
await fetchPersistedSelection();
|
||||
|
||||
for (const url of requestedUrls(fetchMock)) {
|
||||
expect(url).not.toContain('/api/providers');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
||||
* UI depends on. Every response body is untrusted and is normalized through the
|
||||
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
||||
* are mapped to typed, body-free error results so a raw gateway body is never
|
||||
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
||||
*
|
||||
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
||||
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
||||
*/
|
||||
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||
|
||||
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
||||
* never a thrown raw body. */
|
||||
export type CatalogResult =
|
||||
| { ok: true; catalog: HarnessCatalog }
|
||||
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
||||
|
||||
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
||||
|
||||
/** A persist either confirms the stored tuple or reports a typed domain failure
|
||||
* that echoes back the exact tuple the caller requested. */
|
||||
export type SelectionPersistResult =
|
||||
| { ok: true; selection: HarnessSelection }
|
||||
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
||||
|
||||
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
||||
* never surfaced. */
|
||||
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
||||
|
||||
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
||||
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
||||
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
||||
* read from the body. */
|
||||
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
||||
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||
const code = (body as { code: unknown }).code;
|
||||
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
||||
}
|
||||
// Default to the more conservative "invalid" classification for anything
|
||||
// unrecognized rather than guessing "model_unavailable".
|
||||
return 'selection_invalid';
|
||||
}
|
||||
|
||||
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
||||
* normalizes to an empty list (the UI then has no harness to select). */
|
||||
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
||||
const response = await fetch('/api/harnesses', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
return asHarnessSummaries(await readJson(response));
|
||||
}
|
||||
|
||||
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
||||
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
||||
* fallback catalog or a rendered raw body. */
|
||||
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
||||
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'catalog_unavailable',
|
||||
// Scoped to the requested harness id, never the untrusted body's echo.
|
||||
harnessId,
|
||||
message: CATALOG_UNAVAILABLE_MESSAGE,
|
||||
};
|
||||
}
|
||||
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
||||
}
|
||||
|
||||
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
||||
* unset or malformed. */
|
||||
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
||||
const response = await fetch('/api/chat/preferences/selection', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const body = await readJson(response);
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
return asHarnessSelection((body as { selection?: unknown }).selection);
|
||||
}
|
||||
|
||||
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
||||
* On success returns the confirmed selection; on a typed domain failure (422)
|
||||
* or validation error, returns a typed result carrying the EXACT requested
|
||||
* tuple — never the body's echo — and never the raw body text. */
|
||||
export async function persistSelection(
|
||||
selection: HarnessSelection,
|
||||
): Promise<SelectionPersistResult> {
|
||||
const requested: HarnessSelection = {
|
||||
harnessId: selection.harnessId,
|
||||
providerId: selection.providerId,
|
||||
modelId: selection.modelId,
|
||||
};
|
||||
const response = await fetch('/api/chat/preferences/selection', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requested),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await readJson(response);
|
||||
return {
|
||||
ok: false,
|
||||
code: safeSelectionCode(body),
|
||||
// Fixed copy only — the untrusted body's message is never surfaced.
|
||||
message: SELECTION_REJECTED_MESSAGE,
|
||||
requested,
|
||||
};
|
||||
}
|
||||
const body = await readJson(response);
|
||||
const confirmed =
|
||||
typeof body === 'object' && body !== null
|
||||
? asHarnessSelection((body as { selection?: unknown }).selection)
|
||||
: null;
|
||||
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
||||
// the server accepted the tuple, so the requested tuple is the source of truth.
|
||||
return { ok: true, selection: confirmed ?? requested };
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||
import type { HarnessSelection } from '@/lib/types';
|
||||
import type { HarnessSelectionValue } from './use-harness-selection';
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
onSend: (input: { content: string; selection: HarnessSelection }) => boolean;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
@@ -9,6 +11,23 @@ interface ComposerProps {
|
||||
* pre-ack window where a second send could otherwise slip through. */
|
||||
sending: boolean;
|
||||
hasConversation: boolean;
|
||||
/** Structured harness/provider/model selection state. The composer never
|
||||
* accepts free-text provider/model — every sendable tuple is a validated,
|
||||
* persisted catalog entry, and the send projection is derived from it. */
|
||||
harness: HarnessSelectionValue;
|
||||
}
|
||||
|
||||
/** The distinct provider ids present in the current catalog, in first-seen
|
||||
* order — the provider select is catalog-derived, never a hardcoded list. */
|
||||
function providerOptions(harness: HarnessSelectionValue): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const model of harness.catalog?.models ?? []) {
|
||||
if (seen.has(model.providerId)) continue;
|
||||
seen.add(model.providerId);
|
||||
out.push(model.providerId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
@@ -17,22 +36,26 @@ export function Composer({
|
||||
streaming,
|
||||
sending,
|
||||
hasConversation,
|
||||
harness,
|
||||
}: ComposerProps): ReactElement {
|
||||
const [content, setContent] = useState('');
|
||||
const [provider, setProvider] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const busy = streaming || sending;
|
||||
|
||||
function submit(): void {
|
||||
if (busy) return;
|
||||
// Send is gated on a validated, persisted catalog tuple — a draft or unset
|
||||
// selection can never emit, so provider/model never travel as free text.
|
||||
if (!harness.canSend || harness.persistedSelection === null) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
onSend({
|
||||
content: trimmed,
|
||||
provider: provider.trim() || undefined,
|
||||
modelId: modelId.trim() || undefined,
|
||||
});
|
||||
setContent('');
|
||||
// Pass the validated, persisted selection tuple only. The hook derives the
|
||||
// wire projection (legacy `message` provider/model, or `turn:send`) from the
|
||||
// negotiated `chat:send-capability` protocol — never from flat caller input.
|
||||
const selection = harness.persistedSelection;
|
||||
const ok = onSend({ content: trimmed, selection });
|
||||
// Clear the input only when the send was accepted — a refused turn (e.g. a
|
||||
// failed idempotency mint) must retain the user's text so it is not lost.
|
||||
if (ok) setContent('');
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
@@ -42,6 +65,19 @@ export function Composer({
|
||||
}
|
||||
}
|
||||
|
||||
// Scope the model options to the intentionally selected provider. With no
|
||||
// provider chosen (`providerId === ''`) nothing matches, so the model select
|
||||
// offers only the placeholder — never a cross-provider row.
|
||||
const models = (harness.catalog?.models ?? []).filter(
|
||||
(model) => model.providerId === harness.providerId,
|
||||
);
|
||||
// A collision-safe composite option identity covering the full provider+model
|
||||
// tuple. The controlled select mirrors the same identity so the exact catalog
|
||||
// row highlights (a bare modelId would collide across providers).
|
||||
const modelOptionValue = (model: { providerId: string; modelId: string }): string =>
|
||||
`${model.providerId}:${model.modelId}`;
|
||||
const selectedModelValue = harness.modelId ? `${harness.providerId}:${harness.modelId}` : '';
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
@@ -51,21 +87,68 @@ export function Composer({
|
||||
className="flex flex-col gap-2 border-t p-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
<select
|
||||
aria-label="Harness"
|
||||
value={harness.harnessId}
|
||||
onChange={(event) => harness.selectHarness(event.target.value)}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="">Select a harness…</option>
|
||||
{harness.harnesses.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Provider"
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
placeholder="Provider (optional)"
|
||||
value={harness.providerId}
|
||||
onChange={(event) => harness.selectProvider(event.target.value)}
|
||||
disabled={harness.catalogUnavailable || providerOptions(harness).length === 0}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
>
|
||||
<option value="">Select a provider…</option>
|
||||
{providerOptions(harness).map((providerId) => (
|
||||
<option key={providerId} value={providerId}>
|
||||
{providerId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Model"
|
||||
value={modelId}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
placeholder="Model (optional)"
|
||||
value={selectedModelValue}
|
||||
onChange={(event) => {
|
||||
// Resolve the composite option identity back to the exact catalog
|
||||
// row and persist that row's own provider+model — never a bare id.
|
||||
const selected = models.find((model) => modelOptionValue(model) === event.target.value);
|
||||
if (selected) harness.selectModel(selected.providerId, selected.modelId);
|
||||
}}
|
||||
disabled={harness.catalogUnavailable || models.length === 0}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
/>
|
||||
>
|
||||
<option value="">Select a model…</option>
|
||||
{models.map((model) => (
|
||||
<option key={modelOptionValue(model)} value={modelOptionValue(model)}>
|
||||
{model.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{harness.catalogUnavailable ? (
|
||||
<p role="status" className="text-xs opacity-70">
|
||||
This harness catalog is currently unavailable.
|
||||
</p>
|
||||
) : null}
|
||||
{harness.isStale ? (
|
||||
<p role="status" className="text-xs opacity-70">
|
||||
The saved model is no longer available — pick another to continue.
|
||||
</p>
|
||||
) : null}
|
||||
{harness.persistError ? (
|
||||
<p role="alert" className="text-xs">
|
||||
{harness.persistError.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
@@ -78,7 +161,7 @@ export function Composer({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || busy}
|
||||
disabled={!content.trim() || busy || !harness.canSend}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
* a non-array, `.toFixed` on a non-number) or render an object as a React
|
||||
* child.
|
||||
*/
|
||||
import type {
|
||||
HarnessAuthState,
|
||||
HarnessCatalog,
|
||||
HarnessCatalogEntry,
|
||||
HarnessModelAvailability,
|
||||
HarnessSelection,
|
||||
HarnessSummary,
|
||||
} from '@/lib/types';
|
||||
|
||||
export function asString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
@@ -38,6 +46,99 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP harness/catalog/selection JSON bodies are as untrusted as the socket
|
||||
* payloads above — a misbehaving or compromised gateway can send anything. The
|
||||
* guards below normalize those bodies into the typed client shapes without ever
|
||||
* rendering a raw body, so a 404/422/malformed response can never inject an
|
||||
* object into React or a non-tuple into the selection state.
|
||||
*/
|
||||
|
||||
/** Normalizes an untrusted `authState` to the closed set, defaulting to the
|
||||
* safest value (`unavailable`) for anything unrecognized. */
|
||||
export function asHarnessAuthState(value: unknown): HarnessAuthState {
|
||||
return value === 'ready' || value === 'auth_required' || value === 'unavailable'
|
||||
? value
|
||||
: 'unavailable';
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted `availability` to the closed set, defaulting to
|
||||
* `unavailable` so a malformed row can never present as sendable. */
|
||||
export function asHarnessAvailability(value: unknown): HarnessModelAvailability {
|
||||
return value === 'available' ? 'available' : 'unavailable';
|
||||
}
|
||||
|
||||
/** A tuple is valid only when all three ids are non-empty strings — a partial
|
||||
* or malformed selection is rejected (null) rather than half-adopted. */
|
||||
export function asHarnessSelection(value: unknown): HarnessSelection | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const harnessId = value.harnessId;
|
||||
const providerId = value.providerId;
|
||||
const modelId = value.modelId;
|
||||
if (
|
||||
typeof harnessId !== 'string' ||
|
||||
typeof providerId !== 'string' ||
|
||||
typeof modelId !== 'string' ||
|
||||
harnessId.length === 0 ||
|
||||
providerId.length === 0 ||
|
||||
modelId.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { harnessId, providerId, modelId };
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted array into typed harness summaries, dropping any row
|
||||
* without a usable id. */
|
||||
export function asHarnessSummaries(value: unknown): HarnessSummary[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: HarnessSummary[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const id = asString(item.id);
|
||||
if (id.length === 0) continue;
|
||||
out.push({
|
||||
id,
|
||||
displayName: asNonEmptyString(item.displayName, id),
|
||||
capabilities: asStringArray(item.capabilities),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asHarnessCatalogEntry(value: unknown): HarnessCatalogEntry | null {
|
||||
const selection = asHarnessSelection(value);
|
||||
if (selection === null || !isRecord(value)) return null;
|
||||
return {
|
||||
...selection,
|
||||
displayName: asNonEmptyString(value.displayName, selection.modelId),
|
||||
reasoningCapability: value.reasoningCapability === true,
|
||||
inputTypes: asStringArray(value.inputTypes),
|
||||
authState: asHarnessAuthState(value.authState),
|
||||
availability: asHarnessAvailability(value.availability),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes an untrusted catalog body into the typed client catalog. The
|
||||
* caller supplies `harnessId` (from the request path) so the returned catalog
|
||||
* is scoped to the harness that was actually requested, never a body-echoed id.
|
||||
* Malformed model rows are dropped rather than invalidating the whole catalog. */
|
||||
export function asHarnessCatalog(value: unknown, harnessId: string): HarnessCatalog {
|
||||
const record = isRecord(value) ? value : {};
|
||||
const rawModels = Array.isArray(record.models) ? record.models : [];
|
||||
const models: HarnessCatalogEntry[] = [];
|
||||
for (const row of rawModels) {
|
||||
const entry = asHarnessCatalogEntry(row);
|
||||
if (entry !== null) models.push(entry);
|
||||
}
|
||||
return {
|
||||
harnessId,
|
||||
version: asString(record.version),
|
||||
fingerprint: asString(record.fingerprint),
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/** The single point of truth for what counts as a valid conversation ID
|
||||
* anywhere a scoped server event may adopt one into state — a non-empty
|
||||
* string, nothing else. Every site that establishes or compares
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
/** The subset of a Socket.IO `ChatSocket` that `useChatConnection` drives. */
|
||||
export interface FakeChatSocket {
|
||||
connected: boolean;
|
||||
/** Mirrors socket.io-client's `Socket.id`: the connection identity the server
|
||||
* echoes in a `chat:send-capability` payload. The generation-bound send
|
||||
* protocol accepts an advertisement only when `payload.connectionId === id`. */
|
||||
id: string;
|
||||
connect(): FakeChatSocket;
|
||||
on<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
off<K extends ServerEvent>(event: K, handler: ServerHandler<K>): FakeChatSocket;
|
||||
@@ -51,8 +55,10 @@ export function createFakeChatSocket(): {
|
||||
/** Simulates socket.io-client's automatic reconnect of the *same*
|
||||
* instance after a transient disconnect: marks the socket connected again
|
||||
* and fires any handler(s) registered via `socket.on('connect', ...)`,
|
||||
* without clearing or replacing any listeners. */
|
||||
simulateReconnect(): void;
|
||||
* without clearing or replacing any listeners. A real reconnect is assigned
|
||||
* a fresh `Socket.id`; pass `nextId` to model that new connection identity
|
||||
* (defaults to the current id so existing callers are unaffected). */
|
||||
simulateReconnect(nextId?: string): void;
|
||||
} {
|
||||
const listeners = new Map<ServerEvent, Set<(payload: never) => void>>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
@@ -63,6 +69,7 @@ export function createFakeChatSocket(): {
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
id: 'socket-a',
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
@@ -105,8 +112,9 @@ export function createFakeChatSocket(): {
|
||||
}
|
||||
}
|
||||
|
||||
function simulateReconnect(): void {
|
||||
function simulateReconnect(nextId: string = socket.id): void {
|
||||
socket.connected = true;
|
||||
socket.id = nextId;
|
||||
const lifecycleKey = 'connect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => void)();
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('@/lib/socket', () => ({
|
||||
destroySocket: destroySocketMock,
|
||||
}));
|
||||
|
||||
import type { ChatSendProtocol, HarnessSelection } from '@mosaicstack/types';
|
||||
import { useChatConnection, type ChatConnectionValue } from './use-chat-connection';
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
@@ -33,6 +34,126 @@ function Harness(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, Step Two (web send path) red-first support. These probe the FUTURE
|
||||
* pi-rpc send contract against the CURRENT implementation, so the desired API is
|
||||
* expressed here as a localized cast — production types stay untouched until Step
|
||||
* Three. The reds fail on behaviour (legacy `message` emitted instead of
|
||||
* `turn:send`; no nested selection; no idempotency key; void return; no
|
||||
* conversation-id gating), never on a missing module or type.
|
||||
*/
|
||||
interface HarnessTurnSendInput {
|
||||
readonly content: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
type HarnessSendMessage = (input: HarnessTurnSendInput) => boolean;
|
||||
|
||||
function harnessSend(): HarnessSendMessage {
|
||||
return latest?.actions.sendMessage as unknown as HarnessSendMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five MAJOR-1 (browser send-protocol negotiation) support. The Gateway
|
||||
* advertises how this connection may send via a server-to-client-only
|
||||
* `chat:send-capability` (already part of the typed `ServerToClientEvents`
|
||||
* contract, so this uses the fake's typed `serverEmit` — no cast); the hook
|
||||
* holds the advertised protocol and routes `sendMessage` through an exhaustive
|
||||
* switch on it, never inferring it from conversation/selection. When no listener
|
||||
* is registered yet (CURRENT impl), the emit is an inert no-op, so the reds
|
||||
* below fail on BEHAVIOUR — the current send path still infers a protocol and
|
||||
* emits regardless of any advertisement — not on a missing module or type.
|
||||
*/
|
||||
function advertiseCapability(protocol: ChatSendProtocol, connectionId: string): void {
|
||||
fake.serverEmit('chat:send-capability', { protocol, connectionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a controllable `crypto.randomUUID` on the global crypto object and
|
||||
* return a restore fn. Uses defineProperty on the instance so it works whether
|
||||
* or not the native method is configurable (it lives on the prototype, so an own
|
||||
* property simply shadows it).
|
||||
*/
|
||||
function installRandomUUID(fn: () => string): () => void {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (!g.crypto) {
|
||||
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||
}
|
||||
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force `crypto.randomUUID` to read as ABSENT by shadowing it with an own
|
||||
* `undefined` property. The native method lives on `Crypto.prototype`, so a
|
||||
* bare delete of the (non-existent) own property would leave the inherited
|
||||
* method visible — the shadow is what actually makes the call site see no
|
||||
* secure generator. Returns a restore fn.
|
||||
*/
|
||||
function removeRandomUUID(): () => void {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (!g.crypto) {
|
||||
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||
}
|
||||
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five, Step Two group 4/5 support — the FUTURE `turn:ack` receipt surface
|
||||
* and the FUTURE fixed idempotency/rejection notice, expressed as a localized
|
||||
* read-only view over `state`. Production `ChatConnectionState` gains
|
||||
* `turnReceipt` at Step Three; the cast keeps production types untouched until
|
||||
* then, so a success assertion against it fails on BEHAVIOUR (no turn:ack
|
||||
* handler runs), never on a missing module. `error` already exists on state.
|
||||
*/
|
||||
interface HarnessTurnReceiptView {
|
||||
readonly idempotencyKey: string;
|
||||
readonly receiptId: string;
|
||||
readonly selection: HarnessSelection;
|
||||
}
|
||||
interface HarnessTurnStateView {
|
||||
readonly turnReceipt: HarnessTurnReceiptView | null | undefined;
|
||||
readonly error: string | null;
|
||||
}
|
||||
function harnessTurnState(): HarnessTurnStateView {
|
||||
return latest?.state as unknown as HarnessTurnStateView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a server `turn:ack` the CURRENT hook has no listener for — a safe no-op
|
||||
* today (the fake iterates an empty handler set), so the group-4 reds fail
|
||||
* because nothing is surfaced, not because this throws. The event name is cast
|
||||
* past the compile-time `ServerToClientEvents` contract exactly as the
|
||||
* `turn:send` client cast is; the typed event map lands at Step Three.
|
||||
*/
|
||||
function serverEmitTurnAck(payload: unknown): void {
|
||||
fake.serverEmitRaw('turn:ack' as unknown as Parameters<typeof fake.serverEmitRaw>[0], payload);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
@@ -67,6 +188,20 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('useChatConnection', () => {
|
||||
// Task Five MAJOR-1: the send path is PROTOCOL-driven — `sendMessage` routes
|
||||
// only on the negotiated `chat:send-capability`, never on inferred
|
||||
// conversation/selection state. These pre-existing cases exercise the legacy
|
||||
// `message` branch, so the connection is advertised `legacy-message` once here
|
||||
// (server-to-client, for this exact socket id) after the mount registers its
|
||||
// listener. Sub-describes that need the pi turn-runtime reset the generation
|
||||
// and re-advertise `turn-send`; the capability describe resets to the
|
||||
// unadvertised `unavailable` baseline and drives the protocol itself.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('establishes the active conversation from the first message:ack when message omitted conversationId', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
@@ -344,7 +479,10 @@ describe('useChatConnection', () => {
|
||||
|
||||
it('sendMessage emits optional conversationId/provider/modelId and appends an optimistic user turn', async () => {
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'hello', provider: 'anthropic', modelId: 'claude' });
|
||||
latest?.actions.sendMessage({
|
||||
content: 'hello',
|
||||
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
@@ -373,6 +511,408 @@ describe('useChatConnection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:send harness routing (Task Five, Step Two red-first)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// The pi turn-runtime routes sends through `turn:send`. Reset the generation
|
||||
// (clearing the outer `legacy-message` advertisement + first-wins lock) and
|
||||
// advertise `turn-send` for this exact connection, so every send below takes
|
||||
// the turn-runtime branch.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establishConversation(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('emits a single turn:send with the nested selection tuple and a UUID idempotencyKey — never the legacy message event', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(sends[0]?.payload).toEqual({
|
||||
conversationId: 'c1',
|
||||
content: 'hello',
|
||||
selection,
|
||||
idempotencyKey: UUID,
|
||||
});
|
||||
// The pi-rpc sender must not fall back to the embedded `message` event.
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
});
|
||||
|
||||
it('generates the idempotencyKey with exactly one crypto.randomUUID() call per accepted send', async () => {
|
||||
const gen = vi.fn(() => UUID);
|
||||
const restore = installRandomUUID(gen);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(gen).toHaveBeenCalledTimes(2);
|
||||
const keys = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
expect(keys).toEqual([UUID, UUID]);
|
||||
});
|
||||
|
||||
it('does not send before an active conversation id exists (no first-send auto-create)', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'too early', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
// Nothing optimistically appended when the send is refused.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'too early')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when it emits and false when the send is refused', async () => {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let refusedEarly: boolean | undefined;
|
||||
let acceptedAfter: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
refusedEarly = harnessSend()({ content: 'early', selection });
|
||||
});
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
acceptedAfter = harnessSend()({ content: 'now', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(refusedEarly).toBe(false);
|
||||
expect(acceptedAfter).toBe(true);
|
||||
});
|
||||
|
||||
it('when secure UUID generation throws: emits nothing, appends nothing, releases the lock, and a later send succeeds', async () => {
|
||||
await establishConversation();
|
||||
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('secure random unavailable');
|
||||
});
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'blocked', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'blocked')).toBe(false);
|
||||
|
||||
// The send lock must have been released, so a subsequent valid send works.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'retry', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('turn:ack receipt + rejection contract (Task Five, Step Two group 4)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
// turn:ack is the receipt for a `turn:send`, so these establish under the pi
|
||||
// turn-runtime: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Establish the conversation and send one accepted turn under a controlled
|
||||
// idempotency key. Returns the crypto restore fn so callers unwind it.
|
||||
async function establishAndSend(): Promise<() => void> {
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hello', selection });
|
||||
});
|
||||
return restore;
|
||||
}
|
||||
|
||||
it('surfaces a turn:ack receipt echoing the exact idempotencyKey, server receiptId, and requested selection tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: no turn:ack handler exists, so nothing is recorded. Green
|
||||
// only when Step Three echoes the exact tuple back into state — never a
|
||||
// substituted or fabricated one.
|
||||
expect(harnessTurnState().turnReceipt).toEqual({
|
||||
idempotencyKey: UUID,
|
||||
receiptId: 'r1',
|
||||
selection,
|
||||
});
|
||||
});
|
||||
|
||||
it('on a rejected turn:ack surfaces a visible safe notice, never the raw internal error, and fabricates no receipt tuple', async () => {
|
||||
const restore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'ADAPTER_BOOM internal stack: pi adapter unavailable at 0xdeadbeef',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// RED anchor: a rejected ack must surface a visible notice; today no
|
||||
// handler runs, so state.error stays null.
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
// The raw internal exception text must never reach the browser surface.
|
||||
expect(harnessTurnState().error ?? '').not.toContain('ADAPTER_BOOM');
|
||||
expect(harnessTurnState().error ?? '').not.toContain('0xdeadbeef');
|
||||
// A rejection must not fabricate a success receipt tuple.
|
||||
expect(harnessTurnState().turnReceipt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('uses one fixed safe rejection notice regardless of the internal cause (frozen union, not a passthrough)', async () => {
|
||||
const firstRestore = await establishAndSend();
|
||||
try {
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-ALPHA adapter_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
firstRestore();
|
||||
}
|
||||
const firstNotice = harnessTurnState().error;
|
||||
|
||||
// A fresh turn on the same conversation, rejected for a DIFFERENT internal
|
||||
// reason, must surface the identical fixed notice.
|
||||
const secondRestore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'again', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
serverEmitTurnAck({
|
||||
conversationId: 'c1',
|
||||
idempotencyKey: UUID,
|
||||
ok: false,
|
||||
code: 'runtime_unsupported',
|
||||
error: 'cause-BRAVO conversation_service_unavailable',
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
secondRestore();
|
||||
}
|
||||
const secondNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today; green requires a single frozen safe
|
||||
// string surfaced for both distinct internal causes.
|
||||
expect(firstNotice).toBeTruthy();
|
||||
expect(secondNotice).toBeTruthy();
|
||||
expect(firstNotice).toBe(secondNotice);
|
||||
expect(firstNotice ?? '').not.toContain('ALPHA');
|
||||
expect(secondNotice ?? '').not.toContain('BRAVO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency-key failure semantics (Task Five, Step Two group 5)', () => {
|
||||
const selection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID_A = '11111111-1111-4111-8111-111111111111';
|
||||
const UUID_B = '22222222-2222-4222-9222-222222222222';
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// The idempotency key is minted only on the pi turn-runtime `turn:send`
|
||||
// branch: reset the generation (clearing the outer `legacy-message`
|
||||
// advertisement + lock) and advertise `turn-send` for this connection.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function establish(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
it('mints a DISTINCT UUID-v4 idempotencyKey for each of two accepted turns — a key is never reused across turns', async () => {
|
||||
const keys = [UUID_A, UUID_B];
|
||||
let call = 0;
|
||||
const restore = installRandomUUID(() => keys[call++] ?? UUID_A);
|
||||
try {
|
||||
await establish();
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'first', selection });
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'second', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sent = fake.emitted
|
||||
.filter((e) => e.event === 'turn:send')
|
||||
.map((e) => (e.payload as { idempotencyKey: string }).idempotencyKey);
|
||||
// RED anchor: current sendMessage emits the legacy `message`, so no
|
||||
// turn:send keys exist at all.
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[0]).toMatch(UUID_V4);
|
||||
expect(sent[1]).toMatch(UUID_V4);
|
||||
expect(sent[0]).not.toBe(sent[1]);
|
||||
});
|
||||
|
||||
it('when crypto.randomUUID is ABSENT: surfaces a visible fixed idempotency-unavailable notice, emits nothing, appends nothing, releases the lock synchronously, and a later valid send succeeds', async () => {
|
||||
await establish();
|
||||
|
||||
const restoreCrypto = removeRandomUUID();
|
||||
let firstReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
firstReturn = harnessSend()({ content: 'no-secure-random', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreCrypto();
|
||||
}
|
||||
|
||||
// RED anchors: a refused send returns false and surfaces a visible notice.
|
||||
expect(firstReturn).toBe(false);
|
||||
expect(harnessTurnState().error).toBeTruthy();
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(latest?.state.messages.some((m) => m.text === 'no-secure-random')).toBe(false);
|
||||
|
||||
// The lock released synchronously (no server event needed): a later valid
|
||||
// send goes through.
|
||||
const restore = installRandomUUID(() => UUID_A);
|
||||
let secondReturn: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
secondReturn = harnessSend()({ content: 'recovered', selection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
expect(secondReturn).toBe(true);
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces the SAME fixed idempotency-unavailable notice whether randomUUID is absent or throws, never leaking the thrown message', async () => {
|
||||
// Case 1: absent.
|
||||
await establish();
|
||||
const restoreAbsent = removeRandomUUID();
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'absent', selection });
|
||||
});
|
||||
} finally {
|
||||
restoreAbsent();
|
||||
}
|
||||
const absentNotice = harnessTurnState().error;
|
||||
|
||||
// Case 2: throws with a distinctive internal message.
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('SECURE_RANDOM_BOOM entropy pool drained');
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'throws', selection });
|
||||
});
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
const throwNotice = harnessTurnState().error;
|
||||
|
||||
// RED anchor: both are null today.
|
||||
expect(absentNotice).toBeTruthy();
|
||||
expect(throwNotice).toBeTruthy();
|
||||
expect(absentNotice).toBe(throwNotice);
|
||||
// The thrown internal detail must never reach the browser surface.
|
||||
expect(throwNotice ?? '').not.toContain('SECURE_RANDOM_BOOM');
|
||||
expect(throwNotice ?? '').not.toContain('entropy pool');
|
||||
});
|
||||
});
|
||||
|
||||
it('abort emits abort with the active conversationId', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
@@ -684,7 +1224,16 @@ describe('useChatConnection', () => {
|
||||
expect(latest?.state.approvalRequestPending).toBe(false);
|
||||
|
||||
// The send lock must also be released — a subsequent sendMessage after
|
||||
// reconnect must not be permanently blocked by the interrupted turn.
|
||||
// reconnect must not be permanently blocked by the interrupted turn. The
|
||||
// disconnect also voids the negotiated send protocol (MAJOR-1), so model the
|
||||
// reconnect handshake — the socket reconnects and the server re-advertises
|
||||
// how this connection may send — before probing the released lock.
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
latest?.actions.sendMessage({ content: 'after reconnect' });
|
||||
});
|
||||
@@ -1665,4 +2214,319 @@ describe('useChatConnection', () => {
|
||||
}
|
||||
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('chat:send-capability protocol negotiation (Task Five MAJOR-1, red-first)', () => {
|
||||
const capSelection: HarnessSelection = {
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
};
|
||||
const UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
// The one fixed, safe user-facing notice the hook must surface (code
|
||||
// `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||
// advertised protocol is `unavailable`/unknown/absent. Contract-frozen string.
|
||||
const UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||
|
||||
// These tests each drive the protocol negotiation themselves, so they must
|
||||
// start from a clean, unadvertised generation. Reconnect resets protocolRef
|
||||
// to `unavailable` and clears the outer `legacy-message` first-wins lock
|
||||
// WITHOUT advertising — no client emit, so `fake.emitted` stays empty and the
|
||||
// "starts unavailable" premise holds.
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
});
|
||||
|
||||
async function establishConversation(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
}
|
||||
|
||||
function connectCalls(): number {
|
||||
return (fake.socket.connect as unknown as { mock: { calls: unknown[] } }).mock.calls.length;
|
||||
}
|
||||
|
||||
it('starts with no advertised protocol: a send is refused, emits nothing, mints no key, and surfaces the fixed unavailable notice', async () => {
|
||||
// No `chat:send-capability` has arrived, so the connection has not been told
|
||||
// it may send at all. The current impl infers "selection + no conversation +
|
||||
// no flat provider/model → return false" but SURFACES NOTHING — the red is
|
||||
// that the fixed `send_protocol_unavailable` notice is never set.
|
||||
let uuidCalls = 0;
|
||||
const restore = installRandomUUID(() => {
|
||||
uuidCalls += 1;
|
||||
return UUID;
|
||||
});
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
// The test's name promises "mints no key": the unavailable branch must not
|
||||
// reach the idempotency mint at all. Without this assertion a defect that
|
||||
// mints a key before refusing survives.
|
||||
expect(uuidCalls).toBe(0);
|
||||
// ...and no user content may be optimistically appended on refusal.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'hi')).toBe(false);
|
||||
});
|
||||
|
||||
it('legacy-message advertised overrides conversation-inference: an established conversation still routes the legacy message event, never turn:send', async () => {
|
||||
// Same inputs the inference impl routes to `turn:send` (selection + active
|
||||
// conversation). The advertised protocol is authoritative: it must emit the
|
||||
// legacy `message` event instead. Red: current impl emits turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
it('legacy-message advertised with no conversation: derives provider/model from the selection tuple and emits one message', async () => {
|
||||
// The flat provider/modelId caller inputs are gone; the legacy branch must
|
||||
// source them from the confirmed persisted selection. Red: current impl
|
||||
// refuses a bare harness send (selection + no flat fields → return false).
|
||||
let returned: boolean | undefined;
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'first', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(true);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'first',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
|
||||
it('unavailable advertised: refuses even with an active conversation and selection, emits nothing, surfaces the fixed notice', async () => {
|
||||
// Red: current impl ignores the advertisement and emits turn:send.
|
||||
let uuidCalls = 0;
|
||||
const restore = installRandomUUID(() => {
|
||||
uuidCalls += 1;
|
||||
return UUID;
|
||||
});
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('unavailable', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'nope', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
// Refusal must not optimistically append the user's turn to the transcript
|
||||
// (a distinct leak from the emit): the unavailable branch appends nothing.
|
||||
expect(latest?.state.messages.some((m) => m.text === 'nope')).toBe(false);
|
||||
// ...and must not mint an idempotency key on the refused path.
|
||||
expect(uuidCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores an advertisement whose connectionId does not match the socket id: protocol stays unavailable and the send is refused', async () => {
|
||||
// A capability minted for a different (stale/foreign) connection must never
|
||||
// arm this one. Red: current impl has no connection-id gate and emits
|
||||
// turn:send off the inferred path.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', 'a-different-connection');
|
||||
});
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'spoof', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
});
|
||||
|
||||
it('accepts only the first advertisement for the generation: a later conflicting protocol is ignored', async () => {
|
||||
// legacy-message wins; the subsequent turn-send is a replay/conflict and is
|
||||
// dropped. Red: current impl ignores both and infers turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseCapability('turn-send', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'hi', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted.filter((e) => e.event === 'turn:send')).toHaveLength(0);
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: { conversationId: 'c1', content: 'hi', provider: 'anthropic', modelId: 'claude' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to unavailable on disconnect: a later send is refused and never reconnects the socket', async () => {
|
||||
// Disconnect voids the advertised protocol for the generation. The send must
|
||||
// refuse and MUST NOT call socket.connect() to force a reconnection. Red:
|
||||
// current impl keeps the conversation, infers turn:send, and its turn:send
|
||||
// branch calls socket.connect() when the socket is disconnected.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let returned: boolean | undefined;
|
||||
let connectsDuringSend = 0;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.simulateDisconnect();
|
||||
});
|
||||
const before = connectCalls();
|
||||
await act(async () => {
|
||||
returned = harnessSend()({ content: 'after-drop', selection: capSelection });
|
||||
});
|
||||
connectsDuringSend = connectCalls() - before;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(returned).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
expect(connectsDuringSend).toBe(0);
|
||||
});
|
||||
|
||||
it('resets on reconnect to a fresh generation: refuses until re-advertised, then honors the new advertisement', async () => {
|
||||
// A reconnect mints a new Socket.id and a new generation; the prior
|
||||
// advertisement (bound to the old id) is stale and must not carry over. The
|
||||
// hook only trusts a fresh advertisement for the new connection. Red:
|
||||
// current impl has no connect listener and keeps inferring turn:send.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
let refusedAfterReconnect: boolean | undefined;
|
||||
try {
|
||||
await establishConversation();
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
});
|
||||
await act(async () => {
|
||||
fake.simulateReconnect('socket-b');
|
||||
});
|
||||
await act(async () => {
|
||||
refusedAfterReconnect = harnessSend()({ content: 'stale', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(refusedAfterReconnect).toBe(false);
|
||||
expect(fake.emitted).toHaveLength(0);
|
||||
expect(latest?.state.error).toBe(UNAVAILABLE_NOTICE);
|
||||
|
||||
// A fresh advertisement for the reconnected id (socket-b) re-arms sending.
|
||||
const restore2 = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
advertiseCapability('legacy-message', 'socket-b');
|
||||
});
|
||||
await act(async () => {
|
||||
harnessSend()({ content: 'welcome-back', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore2();
|
||||
}
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: 'c1',
|
||||
content: 'welcome-back',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes on the synchronous protocol ref, not the batched reducer mirror: an advertisement and a send in the SAME tick still route by the just-advertised protocol', async () => {
|
||||
// An advertisement lands and a send is issued within one synchronous tick,
|
||||
// before React commits the reducer's `sendProtocol` mirror. The send is
|
||||
// captured from the pre-advertisement render, so its closed-over reducer
|
||||
// state still reads `sendProtocol === 'unavailable'`; the capability
|
||||
// handler, however, has already set the synchronous `protocolRef` to
|
||||
// `legacy-message`. The hook must route on that ref. Red (against a
|
||||
// stale-mirror routing that reads `state.sendProtocol`): the send reads the
|
||||
// pre-advertisement `unavailable` and refuses instead of emitting `message`.
|
||||
const restore = installRandomUUID(() => UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
// Bound to the CURRENT (pre-advertisement) render — its closure still
|
||||
// sees the reset `unavailable` mirror even after the advert dispatches.
|
||||
const sendBeforeCommit = harnessSend();
|
||||
advertiseCapability('legacy-message', fake.socket.id);
|
||||
// Same tick, no await: React has not committed the new mirror yet, so
|
||||
// only `protocolRef` reflects `legacy-message`.
|
||||
sendBeforeCommit({ content: 'same-tick', selection: capSelection });
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'same-tick',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
},
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
asConversationId,
|
||||
asFiniteNumber,
|
||||
asHarnessSelection,
|
||||
asString,
|
||||
asStringArray,
|
||||
isRecord,
|
||||
@@ -21,10 +22,14 @@ import type {
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
MessageAckPayload,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
@@ -130,6 +135,42 @@ const CONVERSATION_START_FAILURE = 'Unable to start this conversation. Please tr
|
||||
* dropped. */
|
||||
const APPROVAL_LIMIT_MESSAGE = 'Approval limit reached for this session. This command was not run.';
|
||||
|
||||
/** Fixed, browser-safe notice surfaced when the harness runtime rejects a turn
|
||||
* (`turn:ack` with `ok:false`). It is deliberately generic: the raw server
|
||||
* `code`/`message`/`error` can carry adapter internals or entropy-source detail,
|
||||
* so no rejection ever leaks its cause into the UI — every distinct rejection
|
||||
* shows this same string. */
|
||||
const TURN_REJECTED_NOTICE = 'This turn could not be sent. Please try again.';
|
||||
|
||||
/** Fixed, browser-safe notice surfaced when a turn is refused because the
|
||||
* idempotency-key mint failed closed (`crypto.randomUUID` absent or throwing).
|
||||
* Like {@link TURN_REJECTED_NOTICE}, it never carries the thrown message. */
|
||||
const IDEMPOTENCY_UNAVAILABLE_NOTICE = 'This turn could not be sent. Please try again.';
|
||||
|
||||
/** The single fixed, browser-safe notice surfaced (with safe code
|
||||
* `send_protocol_unavailable`) when a send is attempted on a connection whose
|
||||
* negotiated send protocol is `unavailable` — the server never advertised a
|
||||
* usable `chat:send-capability`, advertised `unavailable` (e.g. a pi-rpc runtime
|
||||
* in this slice), or the advertisement was rejected (wrong connection id, replay,
|
||||
* or an unknown protocol). It carries no dynamic detail. */
|
||||
const SEND_PROTOCOL_UNAVAILABLE_NOTICE = 'Chat sending is unavailable on this connection.';
|
||||
|
||||
/** Mints a single idempotency key for one accepted `turn:send`, fail-closed.
|
||||
* Returns a fresh RFC-4122 UUID from `crypto.randomUUID`, or `null` when that
|
||||
* source is absent (not a function) or throws — the caller then refuses the turn
|
||||
* rather than falling back to any non-cryptographic source (Math.random, a
|
||||
* clock, or a counter would all be forgeable/collision-prone). Never throws. */
|
||||
function mintIdempotencyKey(): string | null {
|
||||
try {
|
||||
const c: unknown = globalThis.crypto;
|
||||
if (!isRecord(c) || typeof c.randomUUID !== 'function') return null;
|
||||
const key = (c.randomUUID as () => unknown)();
|
||||
return typeof key === 'string' && key.length > 0 ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True only for the narrow case a malformed-conversationId `error`/`agent:end`
|
||||
* must be treated as a terminal startup failure: no conversation has ever been
|
||||
* established yet, and a send is still pending one. Once a conversation is
|
||||
@@ -236,6 +277,14 @@ export interface PendingApproval {
|
||||
args?: string;
|
||||
}
|
||||
|
||||
/** Receipt captured from an accepted harness `turn:ack` — the minimal record proving the
|
||||
* server accepted this exact turn under its minted idempotency key and selection tuple. */
|
||||
export interface HarnessTurnReceipt {
|
||||
idempotencyKey: string;
|
||||
receiptId: string;
|
||||
selection: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface ChatConnectionState {
|
||||
conversationId: string | null;
|
||||
/** True once a message has been sent while no conversation is active yet, so the
|
||||
@@ -268,6 +317,18 @@ export interface ChatConnectionState {
|
||||
approvalRequestPending: boolean;
|
||||
systemReload: SystemReloadPayload | null;
|
||||
error: string | null;
|
||||
/** How this connection is currently permitted to send, negotiated via the
|
||||
* server-to-client-only `chat:send-capability` advertisement. Starts and resets
|
||||
* to `'unavailable'` on every (re)connect and disconnect — a fresh or dropped
|
||||
* connection has no usable protocol until the server (re-)advertises. This is
|
||||
* the reactive/UI mirror of the synchronous `protocolRef` that `sendMessage`
|
||||
* actually reads; the ref is authoritative because an advertisement and a send
|
||||
* can occur in the same tick before React re-renders. */
|
||||
sendProtocol: ChatSendProtocol;
|
||||
/** Receipt from the most recently accepted harness `turn:ack`, or null before any
|
||||
* turn has been accepted. A rejected turn:ack surfaces via `error` and leaves this
|
||||
* untouched (a prior accepted receipt is not erased by a later rejection). */
|
||||
turnReceipt: HarnessTurnReceipt | null;
|
||||
messages: ChatTranscriptMessage[];
|
||||
/** Monotonically increasing counter used to mint transcript message ids —
|
||||
* never reset while retained messages remain, so ids stay unique across the
|
||||
@@ -308,7 +369,7 @@ export interface ChatConnectionState {
|
||||
}
|
||||
|
||||
export interface ChatConnectionActions {
|
||||
sendMessage: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||
sendMessage: (input: { content: string; selection?: HarnessSelection }) => boolean;
|
||||
abort: () => void;
|
||||
setThinking: (level: string) => void;
|
||||
executeCommand: (input: { command: string; args?: string }) => void;
|
||||
@@ -341,6 +402,8 @@ const initialState: ChatConnectionState = {
|
||||
approvalRequestPending: false,
|
||||
systemReload: null,
|
||||
error: null,
|
||||
sendProtocol: 'unavailable',
|
||||
turnReceipt: null,
|
||||
messages: [],
|
||||
messageSeq: 0,
|
||||
toolSeq: 0,
|
||||
@@ -361,7 +424,12 @@ type Action =
|
||||
| { type: 'server/command:approval'; payload: SlashCommandApprovalResultPayload }
|
||||
| { type: 'server/system:reload'; payload: SystemReloadPayload }
|
||||
| { type: 'server/error'; payload: ErrorPayload }
|
||||
| { type: 'server/turn:ack'; payload: HarnessTurnAckPayload }
|
||||
| { type: 'local/send'; content: string }
|
||||
| { type: 'local/capability'; protocol: ChatSendProtocol }
|
||||
| { type: 'local/reset-protocol' }
|
||||
| { type: 'local/send-unavailable' }
|
||||
| { type: 'local/turn-idempotency-unavailable' }
|
||||
| { type: 'local/approve-request'; command: string; args?: string }
|
||||
| { type: 'local/consume-approval' }
|
||||
| { type: 'local/approval-saturated' }
|
||||
@@ -778,6 +846,30 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/turn:ack': {
|
||||
// The harness runtime's turn acknowledgement. The success shape carries a
|
||||
// receipt id + minted idempotencyKey + echoed selection; the failure shape
|
||||
// is discriminated on `ok === false`. Every field is runtime-untrusted (the
|
||||
// top-of-reducer guard already rejected a non-object payload).
|
||||
const record = action.payload as Record<string, unknown>;
|
||||
if (record.ok === false) {
|
||||
// A rejected turn surfaces a FIXED browser-safe notice — never the raw
|
||||
// server `message`/`error`/`code`, which can carry adapter internals — and
|
||||
// does not disturb any previously accepted receipt.
|
||||
return { ...state, error: TURN_REJECTED_NOTICE };
|
||||
}
|
||||
const idempotencyKey = asString(record.idempotencyKey);
|
||||
// The web ack uses `receiptId`; fall back to the frozen contract's `turnId`.
|
||||
const receiptId = asString(record.receiptId) || asString(record.turnId);
|
||||
const selection = asHarnessSelection(record.selection);
|
||||
if (idempotencyKey.length === 0 || receiptId.length === 0 || selection === null) {
|
||||
// A malformed success frame is ignored outright rather than recorded as a
|
||||
// half-populated receipt.
|
||||
return state;
|
||||
}
|
||||
return { ...state, turnReceipt: { idempotencyKey, receiptId, selection } };
|
||||
}
|
||||
|
||||
case 'local/send': {
|
||||
const message: ChatTranscriptMessage = {
|
||||
// Sourced from the reducer-owned `messageSeq` counter — see the
|
||||
@@ -802,6 +894,39 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
case 'local/capability': {
|
||||
// The FIRST valid `chat:send-capability` for this connection generation has
|
||||
// been accepted (connection-id gating + first-wins enforced in the handler);
|
||||
// record how this connection may now send. This is the reactive mirror of
|
||||
// the synchronous `protocolRef` the send path reads.
|
||||
return { ...state, sendProtocol: action.protocol };
|
||||
}
|
||||
|
||||
case 'local/reset-protocol': {
|
||||
// A (re)connect or disconnect voids any negotiated protocol: a fresh or
|
||||
// dropped connection has no usable send capability until the server
|
||||
// (re-)advertises. Reset to `unavailable` so no stale advertisement can
|
||||
// authorize a send across a connection boundary.
|
||||
if (state.sendProtocol === 'unavailable') return state;
|
||||
return { ...state, sendProtocol: 'unavailable' };
|
||||
}
|
||||
|
||||
case 'local/send-unavailable': {
|
||||
// A send was attempted while the negotiated protocol is `unavailable`
|
||||
// (never advertised / advertised unavailable / rejected advertisement).
|
||||
// Surface the single FIXED safe notice — nothing was emitted, minted,
|
||||
// appended, or locked.
|
||||
return { ...state, error: SEND_PROTOCOL_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/turn-idempotency-unavailable': {
|
||||
// The idempotency-key mint failed closed (crypto.randomUUID absent or
|
||||
// throwing), so the turn was refused before emit. Surface a FIXED notice —
|
||||
// never the underlying thrown message, which can leak entropy-source
|
||||
// internals.
|
||||
return { ...state, error: IDEMPOTENCY_UNAVAILABLE_NOTICE };
|
||||
}
|
||||
|
||||
case 'local/disconnect': {
|
||||
// A transient socket disconnect must not leave the UI stuck waiting on
|
||||
// a turn/approval/send that will never resolve on this connection.
|
||||
@@ -882,6 +1007,20 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
approveLockRef.current = state.approvalRequestPending;
|
||||
}, [state.approvalRequestPending]);
|
||||
|
||||
// Synchronous, generation-bound send protocol. `state.sendProtocol` drives the
|
||||
// reactive UI, but reducer updates are batched/async — a `chat:send-capability`
|
||||
// advertisement and a `sendMessage` can land in the same tick before React
|
||||
// re-renders — so this ref is the source of truth the send path reads. Unlike
|
||||
// sendLockRef/approveLockRef (synchronized FROM the reducer), this ref is
|
||||
// written directly by the socket lifecycle/capability handlers below, which
|
||||
// also dispatch the reducer mirror. It is NOT synchronized from state, because
|
||||
// its whole purpose is to be correct BEFORE the reducer has re-rendered.
|
||||
const protocolRef = useRef<ChatSendProtocol>('unavailable');
|
||||
// True once the first valid advertisement for the CURRENT connection generation
|
||||
// has been accepted; every later advertisement (a conflicting or replayed one)
|
||||
// is ignored until the next (re)connect/disconnect resets the generation.
|
||||
const protocolLockedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
|
||||
@@ -913,7 +1052,45 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
const onError = (payload: ErrorPayload): void => {
|
||||
dispatch({ type: 'server/error', payload });
|
||||
};
|
||||
const onTurnAck = (payload: HarnessTurnAckPayload): void =>
|
||||
dispatch({ type: 'server/turn:ack', payload });
|
||||
|
||||
// Void the negotiated send protocol at every connection-lifecycle boundary.
|
||||
// A fresh or dropped connection has no usable capability until the server
|
||||
// (re-)advertises, so no advertisement bound to a prior connection may carry
|
||||
// across the boundary and authorize a send. Both write the synchronous ref
|
||||
// AND unlock first-wins, then dispatch the reducer mirror.
|
||||
const resetSendProtocol = (): void => {
|
||||
protocolRef.current = 'unavailable';
|
||||
protocolLockedRef.current = false;
|
||||
dispatch({ type: 'local/reset-protocol' });
|
||||
};
|
||||
const onConnect = (): void => {
|
||||
resetSendProtocol();
|
||||
};
|
||||
const onCapability = (payload: ChatSendCapabilityPayload): void => {
|
||||
// Server-to-client-only advertisement of how THIS connection may send.
|
||||
// Accept only the FIRST valid one per generation, and only when it names
|
||||
// this exact connection (`connectionId === socket.id`): a capability minted
|
||||
// for another or stale connection must never arm this one. The payload is
|
||||
// runtime-untrusted despite its compile-time type, so every field is
|
||||
// guard-checked and an unknown protocol is dropped (leaving `unavailable`).
|
||||
if (protocolLockedRef.current) return;
|
||||
if (!isRecord(payload)) return;
|
||||
const { protocol, connectionId } = payload as {
|
||||
protocol?: unknown;
|
||||
connectionId?: unknown;
|
||||
};
|
||||
if (typeof connectionId !== 'string' || connectionId !== socket.id) return;
|
||||
if (protocol !== 'legacy-message' && protocol !== 'turn-send' && protocol !== 'unavailable') {
|
||||
return;
|
||||
}
|
||||
protocolLockedRef.current = true;
|
||||
protocolRef.current = protocol;
|
||||
dispatch({ type: 'local/capability', protocol });
|
||||
};
|
||||
const onDisconnect = (): void => {
|
||||
resetSendProtocol();
|
||||
dispatch({ type: 'local/disconnect' });
|
||||
};
|
||||
|
||||
@@ -930,6 +1107,11 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
socket.on('command:approval', onCommandApproval);
|
||||
socket.on('system:reload', onSystemReload);
|
||||
socket.on('error', onError);
|
||||
socket.on('turn:ack', onTurnAck);
|
||||
// Registered BEFORE connect so the initial post-auth advertisement (and any
|
||||
// reconnect) can never race ahead of its listener.
|
||||
socket.on('connect', onConnect);
|
||||
socket.on('chat:send-capability', onCapability);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
|
||||
if (!socket.connected) {
|
||||
@@ -950,24 +1132,79 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
socket.off('command:approval', onCommandApproval);
|
||||
socket.off('system:reload', onSystemReload);
|
||||
socket.off('error', onError);
|
||||
socket.off('turn:ack', onTurnAck);
|
||||
socket.off('connect', onConnect);
|
||||
socket.off('chat:send-capability', onCapability);
|
||||
socket.off('disconnect', onDisconnect);
|
||||
destroySocket();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const actions: ChatConnectionActions = {
|
||||
sendMessage: ({ content, provider, modelId }) => {
|
||||
if (sendLockRef.current || state.streaming || state.sending) return;
|
||||
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();
|
||||
if (!socket.connected) socket.connect();
|
||||
dispatch({ type: 'local/send', content });
|
||||
socket.emit('message', {
|
||||
conversationId: state.conversationId ?? undefined,
|
||||
content,
|
||||
provider,
|
||||
modelId,
|
||||
provider: selection?.providerId,
|
||||
modelId: selection?.modelId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'turn-send': {
|
||||
// Pi turn-runtime: the exclusive `turn:send` contract. Requires an
|
||||
// already-established conversation AND a confirmed persisted selection
|
||||
// tuple; it is lock-independent (no send lock, no optimistic append),
|
||||
// and mints exactly one idempotency key per accepted turn, failing the
|
||||
// turn closed if the mint fails. A premature send (no conversation yet,
|
||||
// or no selection) is refused with no emit and no notice.
|
||||
if (selection == null || state.conversationId === null) return false;
|
||||
const idempotencyKey = mintIdempotencyKey();
|
||||
if (idempotencyKey === null) {
|
||||
dispatch({ type: 'local/turn-idempotency-unavailable' });
|
||||
return false;
|
||||
}
|
||||
const socket = getSocket();
|
||||
socket.emit('turn:send', {
|
||||
conversationId: state.conversationId,
|
||||
content,
|
||||
selection,
|
||||
idempotencyKey,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'unavailable': {
|
||||
// No usable protocol negotiated for this connection: refuse without
|
||||
// emitting, minting, appending, or acquiring the lock, and surface the
|
||||
// one fixed safe notice (code `send_protocol_unavailable`).
|
||||
dispatch({ type: 'local/send-unavailable' });
|
||||
return false;
|
||||
}
|
||||
default: {
|
||||
// Exhaustiveness guard: every ChatSendProtocol member is handled above.
|
||||
// An unknown value can never arm a send — refuse exactly as
|
||||
// `unavailable` rather than falling through to any emit.
|
||||
const _exhaustive: never = protocolRef.current;
|
||||
void _exhaustive;
|
||||
dispatch({ type: 'local/send-unavailable' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
abort: () => {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { act, type ReactElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useHarnessSelection, type HarnessSelectionValue } from './use-harness-selection';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
harnesses?: unknown;
|
||||
catalog?: { body: unknown; status?: number };
|
||||
selection?: unknown;
|
||||
/** When set, the PUT resolves only when this is called (for race tests). */
|
||||
deferPut?: boolean;
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function defer<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let putBodies: unknown[] = [];
|
||||
let putDeferred: Deferred<Response> | null = null;
|
||||
|
||||
function installFetch(scenario: Scenario): ReturnType<typeof vi.fn> {
|
||||
putBodies = [];
|
||||
putDeferred = scenario.deferPut ? defer<Response>() : null;
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses') return json(scenario.harnesses ?? []);
|
||||
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||
const spec = scenario.catalog ?? {
|
||||
body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] },
|
||||
};
|
||||
return json(spec.body, spec.status ?? 200);
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||
return json({ selection: scenario.selection ?? null });
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
putBodies.push(JSON.parse(String(init?.body)));
|
||||
const ok = json({ selection: JSON.parse(String(init?.body)) });
|
||||
if (putDeferred) return putDeferred.promise;
|
||||
return ok;
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
let latest: HarnessSelectionValue | null = null;
|
||||
|
||||
function Probe(): ReactElement | null {
|
||||
latest = useHarnessSelection();
|
||||
return null;
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
latest = null;
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.render(<Probe />);
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function flush(times = 5): Promise<void> {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function value(): HarnessSelectionValue {
|
||||
if (!latest) throw new Error('hook value not captured');
|
||||
return latest;
|
||||
}
|
||||
|
||||
const PI_CATALOG = {
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'fp',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
displayName: 'GPT-5',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
displayName: 'Claude',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('useHarnessSelection', () => {
|
||||
it('loads harnesses and, once a harness is chosen, the model options come only from its catalog', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
|
||||
expect(value().harnesses).toEqual([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
expect(value().catalog).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalog?.harnessId).toBe('pi');
|
||||
expect(value().catalog?.models.map((m) => m.modelId)).toEqual(['gpt-5', 'claude']);
|
||||
});
|
||||
|
||||
it('does not auto-select any catalog row when there is no persisted selection (no first-row fallback)', async () => {
|
||||
const fetchMock = installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().persistedSelection).toBeNull();
|
||||
expect(value().canSend).toBe(false);
|
||||
// Nothing was persisted — no PUT fired for an unset selection.
|
||||
const putCalls = fetchMock.mock.calls.filter(
|
||||
(c) => String((c[1] as RequestInit)?.method).toUpperCase() === 'PUT',
|
||||
);
|
||||
expect(putCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists the structured tuple and only enables send AFTER the PUT resolves (no race ahead of persistence)', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
deferPut: true,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
|
||||
// PUT is in flight (deferred) — send MUST NOT be enabled yet.
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
putDeferred?.resolve(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(putBodies).toContainEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' });
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().canSend).toBe(true);
|
||||
// Task Five: the composer sends the nested `persistedSelection` tuple directly.
|
||||
// The Task-Four compat flat `projection` ({provider, modelId}) is removed — the
|
||||
// harnessId must never be dropped on the way to the wire.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a stale/unavailable persisted selection visibly displayed rather than silently dropping it', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'retired-model' },
|
||||
});
|
||||
await mount();
|
||||
|
||||
// The persisted tuple is displayed even though its model is gone from the catalog.
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'retired-model',
|
||||
});
|
||||
expect(value().modelId).toBe('retired-model');
|
||||
expect(value().isStale).toBe(true);
|
||||
// A stale model is not a valid catalog option, so send stays disabled.
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('disables send for an empty catalog (no viable model) and never fabricates one', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: { harnessId: 'pi', version: '1', fingerprint: 'f', models: [] } },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalog?.models ?? []).toHaveLength(0);
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the catalog unavailable and disables send when the catalog request 404s', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: {
|
||||
body: { code: 'adapter_unavailable', message: 'x', harnessId: 'pi' },
|
||||
status: 404,
|
||||
},
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().catalogUnavailable).toBe(true);
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('on a 422 persist, keeps the requested tuple visible, surfaces a typed error, and leaves send disabled', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: {
|
||||
body: {
|
||||
...PI_CATALOG,
|
||||
models: [{ ...PI_CATALOG.models[0], availability: 'unavailable' }],
|
||||
},
|
||||
},
|
||||
selection: null,
|
||||
});
|
||||
// Override PUT to 422.
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses')
|
||||
return json([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
if (url.endsWith('/catalog')) return json(PI_CATALOG);
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET')
|
||||
return json({ selection: null });
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
return json(
|
||||
{
|
||||
code: 'model_unavailable',
|
||||
message: 'nope',
|
||||
selection: { harnessId: 'a', providerId: 'b', modelId: 'c' },
|
||||
},
|
||||
422,
|
||||
);
|
||||
}
|
||||
return new Response('nf', { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(value().modelId).toBe('gpt-5');
|
||||
expect(value().persistError?.code).toBe('model_unavailable');
|
||||
expect(value().persistError?.requested).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
expect(value().persistedSelection).toBeNull();
|
||||
expect(value().canSend).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates the model on a provider change and keeps send disabled until the new tuple persists', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: null,
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectHarness('pi');
|
||||
});
|
||||
await flush();
|
||||
await act(async () => {
|
||||
value().selectProvider('openai');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('openai', 'gpt-5');
|
||||
});
|
||||
await flush();
|
||||
// A valid provider-A tuple has persisted.
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
});
|
||||
|
||||
// Switching provider clears the model that no longer belongs to it.
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
// Send stays disabled until the new exact provider-B tuple persists.
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
expect(value().persistedSelection).toEqual({
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
});
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not enable send on a model pick until the PUT for that exact new tuple resolves', async () => {
|
||||
installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
deferPut: true,
|
||||
});
|
||||
await mount();
|
||||
// The persisted, in-catalog tuple is sendable after mount (no PUT needed).
|
||||
expect(value().canSend).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
expect(value().modelId).toBe('');
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
// PUT for the new tuple is still in flight — send MUST stay disabled.
|
||||
expect(value().canSend).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
putDeferred?.resolve(
|
||||
json({ selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' } }),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
expect(value().canSend).toBe(true);
|
||||
// Task Five: no compat flat projection — the nested persistedSelection is the wire tuple.
|
||||
expect('projection' in value()).toBe(false);
|
||||
});
|
||||
|
||||
it('never requests any /api/providers* endpoint across the whole flow', async () => {
|
||||
const fetchMock = installFetch({
|
||||
harnesses: [{ id: 'pi', displayName: 'Pi', capabilities: [] }],
|
||||
catalog: { body: PI_CATALOG },
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
});
|
||||
await mount();
|
||||
await act(async () => {
|
||||
value().selectProvider('anthropic');
|
||||
});
|
||||
await act(async () => {
|
||||
value().selectModel('anthropic', 'claude');
|
||||
});
|
||||
await flush();
|
||||
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
expect(String(call[0])).not.toContain('/api/providers');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
fetchCatalog,
|
||||
fetchHarnesses,
|
||||
fetchPersistedSelection,
|
||||
persistSelection,
|
||||
type SelectionErrorCode,
|
||||
} from './chat-api';
|
||||
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
||||
|
||||
export interface HarnessPersistError {
|
||||
code: SelectionErrorCode;
|
||||
message: string;
|
||||
/** The exact tuple the user requested — preserved so the failed selection
|
||||
* stays visible rather than being silently dropped. */
|
||||
requested: HarnessSelection;
|
||||
}
|
||||
|
||||
export interface HarnessSelectionValue {
|
||||
harnesses: HarnessSummary[];
|
||||
catalog: HarnessCatalog | null;
|
||||
/** True when the selected harness has no usable catalog (404/error). */
|
||||
catalogUnavailable: boolean;
|
||||
/** The working (displayed) selection, kept as three distinct ids. Empty
|
||||
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
||||
harnessId: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
/** The last tuple confirmed persisted by the server, or null. */
|
||||
persistedSelection: HarnessSelection | null;
|
||||
/** True when a persisted selection references a model no longer present as an
|
||||
* available catalog entry — it stays visibly displayed rather than dropped. */
|
||||
isStale: boolean;
|
||||
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
||||
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
||||
* can never race ahead of successful persistence. */
|
||||
canSend: boolean;
|
||||
persistError: HarnessPersistError | null;
|
||||
selectHarness: (harnessId: string) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
||||
* resolves the composite option identity to the real entry and passes both
|
||||
* ids, so a bare model id is never combined with ambient provider state. */
|
||||
selectModel: (providerId: string, modelId: string) => void;
|
||||
}
|
||||
|
||||
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
||||
* matching, available entry — the single gate that keeps a stale/unavailable
|
||||
* model from ever counting as sendable. */
|
||||
function isAvailableInCatalog(
|
||||
selection: HarnessSelection | null,
|
||||
catalog: HarnessCatalog | null,
|
||||
): boolean {
|
||||
if (selection === null || catalog === null) return false;
|
||||
return catalog.models.some(
|
||||
(model) =>
|
||||
model.providerId === selection.providerId &&
|
||||
model.modelId === selection.modelId &&
|
||||
model.availability === 'available',
|
||||
);
|
||||
}
|
||||
|
||||
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
||||
if (a === null || b === null) return a === b;
|
||||
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the harness/catalog/selection state for the chat composer: loads the
|
||||
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
||||
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
||||
* when a model is picked. It never auto-selects a catalog row, keeps a
|
||||
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
||||
* once a full tuple has actually persisted as an available catalog entry.
|
||||
*/
|
||||
export function useHarnessSelection(): HarnessSelectionValue {
|
||||
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
||||
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
||||
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
||||
const [harnessId, setHarnessId] = useState('');
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
||||
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
||||
|
||||
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
||||
// overwrite the result of a newer request the user has since triggered.
|
||||
const catalogRequestRef = useRef(0);
|
||||
const persistRequestRef = useRef(0);
|
||||
|
||||
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
||||
const requestId = catalogRequestRef.current + 1;
|
||||
catalogRequestRef.current = requestId;
|
||||
setCatalog(null);
|
||||
setCatalogUnavailable(false);
|
||||
const result = await fetchCatalog(id);
|
||||
if (catalogRequestRef.current !== requestId) return;
|
||||
if (result.ok) {
|
||||
setCatalog(result.catalog);
|
||||
setCatalogUnavailable(false);
|
||||
} else {
|
||||
setCatalog(null);
|
||||
setCatalogUnavailable(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async (): Promise<void> => {
|
||||
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
||||
if (!active) return;
|
||||
setHarnesses(list);
|
||||
if (persisted !== null) {
|
||||
// Adopt the persisted tuple as the displayed selection and load its
|
||||
// catalog. If the model has since been retired, it still shows (stale).
|
||||
setHarnessId(persisted.harnessId);
|
||||
setProviderId(persisted.providerId);
|
||||
setModelId(persisted.modelId);
|
||||
setPersistedSelection(persisted);
|
||||
await loadCatalog(persisted.harnessId);
|
||||
}
|
||||
// No persisted selection → nothing is auto-selected; the user must choose.
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const selectHarness = useCallback(
|
||||
(id: string): void => {
|
||||
setHarnessId(id);
|
||||
// Changing harness invalidates the provider/model draft — never carry a
|
||||
// model across harnesses.
|
||||
setProviderId('');
|
||||
setModelId('');
|
||||
setPersistError(null);
|
||||
void loadCatalog(id);
|
||||
},
|
||||
[loadCatalog],
|
||||
);
|
||||
|
||||
const selectProvider = useCallback((id: string): void => {
|
||||
setProviderId(id);
|
||||
// A new provider invalidates the chosen model — no cross-provider carryover.
|
||||
setModelId('');
|
||||
setPersistError(null);
|
||||
}, []);
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selectedProviderId: string, selectedModelId: string): void => {
|
||||
// Bind the model to the EXACT catalog row's provider — never to ambient
|
||||
// provider state — so two providers exposing the same modelId can never
|
||||
// collide or mis-resolve. Keep the displayed provider consistent with the
|
||||
// resolved row.
|
||||
setProviderId(selectedProviderId);
|
||||
setModelId(selectedModelId);
|
||||
setPersistError(null);
|
||||
const requested: HarnessSelection = {
|
||||
harnessId,
|
||||
providerId: selectedProviderId,
|
||||
modelId: selectedModelId,
|
||||
};
|
||||
const requestId = persistRequestRef.current + 1;
|
||||
persistRequestRef.current = requestId;
|
||||
void (async (): Promise<void> => {
|
||||
const result = await persistSelection(requested);
|
||||
if (persistRequestRef.current !== requestId) return;
|
||||
if (result.ok) {
|
||||
setPersistedSelection(result.selection);
|
||||
setPersistError(null);
|
||||
} else {
|
||||
// Leave persistedSelection unchanged (send stays disabled) and surface
|
||||
// the typed error carrying the exact requested tuple.
|
||||
setPersistError({
|
||||
code: result.code,
|
||||
message: result.message,
|
||||
requested: result.requested,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[harnessId],
|
||||
);
|
||||
|
||||
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
||||
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
||||
const canSend =
|
||||
persistedSelection !== null &&
|
||||
!catalogUnavailable &&
|
||||
tuplesEqual(draft, persistedSelection) &&
|
||||
isAvailableInCatalog(persistedSelection, catalog);
|
||||
|
||||
return {
|
||||
harnesses,
|
||||
catalog,
|
||||
catalogUnavailable,
|
||||
harnessId,
|
||||
providerId,
|
||||
modelId,
|
||||
persistedSelection,
|
||||
isStale,
|
||||
canSend,
|
||||
persistError,
|
||||
selectHarness,
|
||||
selectProvider,
|
||||
selectModel,
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,128 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
return button;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_CATALOG = {
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'fp',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-5',
|
||||
displayName: 'GPT-5',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'anthropic',
|
||||
modelId: 'claude',
|
||||
displayName: 'Claude',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** A harness/catalog/selection HTTP stub for the chat-api the selection hook
|
||||
* drives. `selection` seeds the persisted tuple returned by the GET (a valid
|
||||
* in-catalog tuple by default, so `canSend` settles true after mount). */
|
||||
function harnessFetch(
|
||||
selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
): typeof fetch {
|
||||
return vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses') {
|
||||
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
}
|
||||
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||
return jsonResponse(DEFAULT_CATALOG);
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||
return jsonResponse({ selection });
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
/** Drains the selection hook's chained mount fetches (harnesses → selection →
|
||||
* catalog) and any pending PUT so derived `canSend` settles before assertions. */
|
||||
async function flushAsync(times = 5): Promise<void> {
|
||||
for (let i = 0; i < times; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic idempotency key for the Task Five red-first page send test. */
|
||||
const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
|
||||
/** Install a controllable `crypto.randomUUID` and return a restore fn. Uses
|
||||
* defineProperty on the crypto instance so it works whether or not the native
|
||||
* method is configurable (it lives on the prototype; an own property shadows it). */
|
||||
function installRandomUUID(fn: () => string): () => void {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (!g.crypto) {
|
||||
Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} });
|
||||
}
|
||||
const cryptoObj = g.crypto as { randomUUID?: () => string };
|
||||
const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID');
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return () => {
|
||||
if (original) {
|
||||
Object.defineProperty(cryptoObj, 'randomUUID', original);
|
||||
} else {
|
||||
Reflect.deleteProperty(cryptoObj, 'randomUUID');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only
|
||||
* as the server advertised, once per connection, over the server-to-client-only
|
||||
* `chat:send-capability`. Model that advertisement for THIS connection id so the
|
||||
* page send tests take the intended branch. `legacy-message` is the default
|
||||
* (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests
|
||||
* reset the generation and re-advertise `turn-send` via the helper below.
|
||||
*/
|
||||
function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void {
|
||||
fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id });
|
||||
}
|
||||
|
||||
/** Reset the negotiated protocol to a fresh, unlocked generation (clearing the
|
||||
* default `legacy-message` advertisement + first-wins lock), then advertise the
|
||||
* pi turn-runtime `turn:send` protocol for this connection. The per-test override
|
||||
* for the page send tests that route through `turn:send`. */
|
||||
async function advertiseTurnSendGeneration(): Promise<void> {
|
||||
await act(async () => {
|
||||
fake.simulateReconnect();
|
||||
});
|
||||
await act(async () => {
|
||||
advertiseSendCapability('turn-send');
|
||||
});
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
@@ -57,12 +179,22 @@ beforeEach(async () => {
|
||||
fake = createFakeChatSocket();
|
||||
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||
destroySocketMock.mockReset();
|
||||
vi.stubGlobal('fetch', harnessFetch());
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<ChatPage />);
|
||||
});
|
||||
// Settle the selection hook's mount fetches so the default in-catalog tuple
|
||||
// persists and `canSend` is true for the existing send-path tests.
|
||||
await flushAsync();
|
||||
// Model the server's post-auth send-capability advertisement (MAJOR-1). Most
|
||||
// page send tests exercise the legacy `message` branch; the pi turn-runtime
|
||||
// tests override to `turn-send` via advertiseTurnSendGeneration().
|
||||
await act(async () => {
|
||||
advertiseSendCapability('legacy-message');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -70,8 +202,28 @@ afterEach(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for
|
||||
* tests that need a non-default selection scenario. */
|
||||
async function remountWithFetch(fetchImpl: typeof fetch): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchImpl);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<ChatPage />);
|
||||
});
|
||||
await flushAsync();
|
||||
// Re-advertise on the remounted connection — the prior generation's capability
|
||||
// does not carry across a remount (fresh hook instance, unadvertised protocol).
|
||||
await act(async () => {
|
||||
advertiseSendCapability('legacy-message');
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatPage', () => {
|
||||
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||
await act(async () => {
|
||||
@@ -390,24 +542,62 @@ describe('ChatPage', () => {
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||
it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => {
|
||||
// The old free-text inputs are gone.
|
||||
expect(container.querySelector('input[aria-label="Provider"]')).toBeNull();
|
||||
expect(container.querySelector('input[aria-label="Model"]')).toBeNull();
|
||||
|
||||
const harnessSelect = container.querySelector(
|
||||
'select[aria-label="Harness"]',
|
||||
) as HTMLSelectElement;
|
||||
const providerSelect = container.querySelector(
|
||||
'select[aria-label="Provider"]',
|
||||
) as HTMLSelectElement;
|
||||
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||
expect(harnessSelect).toBeTruthy();
|
||||
expect(providerSelect).toBeTruthy();
|
||||
expect(modelSelect).toBeTruthy();
|
||||
// Harness and provider are distinct controls carrying distinct identifiers.
|
||||
expect(harnessSelect).not.toBe(providerSelect);
|
||||
expect([...harnessSelect.options].map((o) => o.value)).toContain('pi');
|
||||
expect([...providerSelect.options].map((o) => o.value)).toContain('openai');
|
||||
expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic');
|
||||
// The model options are catalog-derived (not hardcoded) and scoped to the
|
||||
// selected provider (openai, from the persisted tuple) using a collision-safe
|
||||
// composite identity — the anthropic row is absent, not a bare 'claude'.
|
||||
const modelValues = [...modelSelect.options].map((o) => o.value);
|
||||
expect(modelValues).toContain('openai:gpt-5');
|
||||
expect(modelValues).not.toContain('anthropic:claude');
|
||||
expect(modelValues).not.toContain('claude');
|
||||
});
|
||||
|
||||
it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
const providerInput = container.querySelector(
|
||||
'input[aria-label="Provider"]',
|
||||
) as HTMLInputElement;
|
||||
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||
|
||||
const stopButtonBefore = container.querySelector(
|
||||
'button[aria-label="Stop"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(stopButtonBefore.disabled).toBe(true);
|
||||
|
||||
// Choose a fresh tuple from the catalog and let it persist.
|
||||
const providerSelect = container.querySelector(
|
||||
'select[aria-label="Provider"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(providerSelect, 'anthropic');
|
||||
});
|
||||
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
// Composite provider+model option identity (provider was switched to
|
||||
// anthropic above); the bare 'claude' no longer identifies an option.
|
||||
selectValue(modelSelect, 'anthropic:claude');
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello there');
|
||||
setValue(providerInput, 'anthropic');
|
||||
setValue(modelInput, 'claude');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
@@ -415,6 +605,7 @@ describe('ChatPage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The projected provider/model come from the validated persisted tuple.
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
@@ -443,6 +634,178 @@ describe('ChatPage', () => {
|
||||
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||
});
|
||||
|
||||
it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => {
|
||||
await advertiseTurnSendGeneration();
|
||||
const restore = installRandomUUID(() => PAGE_UUID);
|
||||
try {
|
||||
// Send is disabled without an active conversation — establish one first.
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
setValue(textarea, 'hello there');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(sends[0]?.payload).toEqual({
|
||||
conversationId: 'c1',
|
||||
content: 'hello there',
|
||||
selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' },
|
||||
idempotencyKey: PAGE_UUID,
|
||||
});
|
||||
// The pi-rpc page send must not emit the embedded `message` event, and must
|
||||
// never send a flat {provider, modelId} that drops the harnessId.
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => {
|
||||
await advertiseTurnSendGeneration();
|
||||
const failing = installRandomUUID(() => {
|
||||
throw new Error('secure random unavailable');
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
setValue(textarea, 'keep me');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// No wire traffic: neither the harness turn nor the legacy message.
|
||||
expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false);
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
// The composer retained its content — it clears ONLY on a successful send,
|
||||
// so the user can retry without retyping.
|
||||
expect(textarea.value).toBe('keep me');
|
||||
// A visible, safe notice explains why nothing was sent.
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
} finally {
|
||||
failing();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => {
|
||||
await advertiseTurnSendGeneration();
|
||||
const restore = installRandomUUID(() => PAGE_UUID);
|
||||
try {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
setValue(textarea, 'ship it');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||
expect(sends).toHaveLength(1);
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
// On a successful send the composer clears.
|
||||
expect(textarea.value).toBe('');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => {
|
||||
await advertiseTurnSendGeneration();
|
||||
const restore = installRandomUUID(() => PAGE_UUID);
|
||||
try {
|
||||
// Change the selection away from the mount default and let it persist.
|
||||
const providerSelect = container.querySelector(
|
||||
'select[aria-label="Provider"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(providerSelect, 'anthropic');
|
||||
});
|
||||
const modelSelect = container.querySelector(
|
||||
'select[aria-label="Model"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(modelSelect, 'anthropic:claude');
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
});
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
setValue(textarea, 'routed');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
const sends = fake.emitted.filter((e) => e.event === 'turn:send');
|
||||
expect(sends).toHaveLength(1);
|
||||
// The nested tuple reflects the CURRENTLY persisted selection, not the
|
||||
// mount default {openai, gpt-5}, and never flat provider/model fields.
|
||||
expect(sends[0]?.payload).toEqual({
|
||||
conversationId: 'c1',
|
||||
content: 'routed',
|
||||
selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' },
|
||||
idempotencyKey: PAGE_UUID,
|
||||
});
|
||||
expect(fake.emitted.some((e) => e.event === 'message')).toBe(false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables send until a selection has persisted — no send with an unset selection', async () => {
|
||||
await remountWithFetch(harnessFetch(null));
|
||||
|
||||
const sendButton = findButton(container, 'Send');
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setValue(textarea, 'should not send');
|
||||
});
|
||||
// Content present, but no selection persisted → Send stays disabled.
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
@@ -620,6 +983,134 @@ describe('ChatPage', () => {
|
||||
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => {
|
||||
await remountWithFetch(harnessFetch(null));
|
||||
|
||||
const harnessSelect = container.querySelector(
|
||||
'select[aria-label="Harness"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(harnessSelect, 'pi');
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
const providerSelect = container.querySelector(
|
||||
'select[aria-label="Provider"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(providerSelect, 'openai');
|
||||
});
|
||||
|
||||
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||
// Only the selected provider's models are offered — provider B's model
|
||||
// (anthropic:claude) is absent, so a user cannot pick across providers.
|
||||
expect(optionValues).toEqual(['openai:gpt-5']);
|
||||
expect(optionValues).not.toContain('anthropic:claude');
|
||||
});
|
||||
|
||||
it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => {
|
||||
const COLLIDING_CATALOG = {
|
||||
harnessId: 'pi',
|
||||
version: '2026-08-11',
|
||||
fingerprint: 'fp',
|
||||
models: [
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'alpha',
|
||||
modelId: 'gpt-x',
|
||||
displayName: 'Alpha GPT-X',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
{
|
||||
harnessId: 'pi',
|
||||
providerId: 'beta',
|
||||
modelId: 'gpt-x',
|
||||
displayName: 'Beta GPT-X',
|
||||
reasoningCapability: true,
|
||||
inputTypes: ['text'],
|
||||
authState: 'ready',
|
||||
availability: 'available',
|
||||
},
|
||||
],
|
||||
};
|
||||
const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
if (url === '/api/harnesses') {
|
||||
return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]);
|
||||
}
|
||||
if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) {
|
||||
return jsonResponse(COLLIDING_CATALOG);
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'GET') {
|
||||
return jsonResponse({ selection: null });
|
||||
}
|
||||
if (url === '/api/chat/preferences/selection' && method === 'PUT') {
|
||||
return jsonResponse({ selection: JSON.parse(String(init?.body)) });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await remountWithFetch(collidingFetch);
|
||||
|
||||
const harnessSelect = container.querySelector(
|
||||
'select[aria-label="Harness"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(harnessSelect, 'pi');
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
const providerSelect = container.querySelector(
|
||||
'select[aria-label="Provider"]',
|
||||
) as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
selectValue(providerSelect, 'alpha');
|
||||
});
|
||||
|
||||
const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||||
// The colliding modelId is provider-qualified in the option value, never a
|
||||
// bare id, so the two providers' 'gpt-x' rows are uniquely identifiable.
|
||||
const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== '');
|
||||
expect(optionValues).toEqual(['alpha:gpt-x']);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(modelSelect, 'alpha:gpt-x');
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// The controlled select highlights the alpha row via the composite identity.
|
||||
expect(modelSelect.value).toBe('alpha:gpt-x');
|
||||
|
||||
const textarea = container.querySelector(
|
||||
'textarea[aria-label="Message"]',
|
||||
) as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
setValue(textarea, 'ping');
|
||||
});
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// The persisted/sent tuple resolves to provider alpha — NOT beta — even
|
||||
// though the bare modelId 'gpt-x' exists under both providers.
|
||||
expect(fake.emitted).toContainEqual({
|
||||
event: 'message',
|
||||
payload: {
|
||||
conversationId: undefined,
|
||||
content: 'ping',
|
||||
provider: 'alpha',
|
||||
modelId: 'gpt-x',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { asFiniteNumberOrNull, asString } from '@/spa/chat/runtime-guards';
|
||||
import { SessionPanel } from '@/spa/chat/session-panel';
|
||||
import { ToolCallList } from '@/spa/chat/tool-call-list';
|
||||
import { useChatConnection } from '@/spa/chat/use-chat-connection';
|
||||
import { useHarnessSelection } from '@/spa/chat/use-harness-selection';
|
||||
|
||||
/** Renders a real value normally, but an honest "unavailable" label instead
|
||||
* of a fabricated `0` for a missing/malformed count — a real `0 tokens` and
|
||||
@@ -23,6 +24,7 @@ function formatCost(value: unknown): string {
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const harness = useHarnessSelection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
@@ -86,6 +88,7 @@ export function ChatPage(): ReactElement {
|
||||
streaming={state.streaming}
|
||||
sending={state.sending}
|
||||
hasConversation={hasConversation}
|
||||
harness={harness}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Mission, Project, Task } from '@/lib/types';
|
||||
|
||||
export const projectFixtures: Project[] = [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Mosaic Stack',
|
||||
description: 'Gateway and dashboard parity work',
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
metadata: {
|
||||
prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.',
|
||||
},
|
||||
createdAt: '2026-08-01T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T18:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'project-2',
|
||||
name: 'Agent Runtime',
|
||||
description: 'Pi SDK integration',
|
||||
status: 'paused',
|
||||
userId: 'user-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-02T08:00:00.000Z',
|
||||
updatedAt: '2026-08-05T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const missionFixtures: Mission[] = [
|
||||
{
|
||||
id: 'mission-1',
|
||||
name: 'Ship web parity',
|
||||
description: 'Port read-only routes into the SPA',
|
||||
status: 'active',
|
||||
projectId: 'project-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-03T12:00:00.000Z',
|
||||
updatedAt: '2026-08-09T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'mission-2',
|
||||
name: 'Unrelated mission',
|
||||
description: 'Must be filtered out of the project detail view',
|
||||
status: 'planning',
|
||||
projectId: 'project-2',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-04T12:00:00.000Z',
|
||||
updatedAt: '2026-08-04T12:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const taskFixtures: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Route /projects',
|
||||
description: 'Port the read-only projects listing into the SPA',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: 'Jarvis',
|
||||
tags: ['spa', 'projects'],
|
||||
dueDate: '2026-08-12T00:00:00.000Z',
|
||||
metadata: {
|
||||
notes: 'Read-only modal content should remain intact.',
|
||||
pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }],
|
||||
},
|
||||
createdAt: '2026-08-04T10:00:00.000Z',
|
||||
updatedAt: '2026-08-09T15:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
title: 'Route /projects/:id',
|
||||
description: 'Reuse overview, tasks, missions, and PRD tabs',
|
||||
status: 'in-progress',
|
||||
priority: 'critical',
|
||||
projectId: 'project-1',
|
||||
missionId: 'mission-1',
|
||||
assignee: null,
|
||||
tags: ['spa', 'detail'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-05T09:00:00.000Z',
|
||||
updatedAt: '2026-08-10T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-3',
|
||||
title: 'Route /tasks',
|
||||
description: 'Wire list and kanban modal interactions',
|
||||
status: 'blocked',
|
||||
priority: 'medium',
|
||||
projectId: 'project-1',
|
||||
missionId: null,
|
||||
assignee: null,
|
||||
tags: ['spa', 'tasks'],
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-08T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'task-4',
|
||||
title: 'Other project task',
|
||||
description: 'Used only to confirm mission filtering remains project-scoped',
|
||||
status: 'not-started',
|
||||
priority: 'low',
|
||||
projectId: 'project-2',
|
||||
missionId: 'mission-2',
|
||||
assignee: null,
|
||||
tags: null,
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-06T09:00:00.000Z',
|
||||
updatedAt: '2026-08-06T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,174 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectDetailPage } from './project-detail';
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <p>Projects index target</p> },
|
||||
{ path: '/projects/:id', element: <ProjectDetailPage /> },
|
||||
];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('ProjectDetailPage', () => {
|
||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(apiMock.mock.calls).toEqual([
|
||||
['/api/projects/project-1'],
|
||||
['/api/missions'],
|
||||
['/api/tasks?projectId=project-1'],
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Route /projects/:id');
|
||||
expect(container.textContent).toContain('Tasks');
|
||||
expect(container.textContent).toContain('Done');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (1)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Ship web parity');
|
||||
expect(container.textContent).not.toContain('Unrelated mission');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('PRD');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack PRD');
|
||||
expect(container.textContent).toContain('Ship the SPA route parity pages.');
|
||||
});
|
||||
|
||||
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (3)');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Read-only modal content should remain intact.');
|
||||
|
||||
const closeButton = container.querySelector('button[aria-label="Close task details"]');
|
||||
expect(closeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (0)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No missions for this project');
|
||||
});
|
||||
|
||||
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Project request failed');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Back to projects');
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
const projectStatusColors: Record<string, string> = {
|
||||
active: 'bg-success/20 text-success',
|
||||
paused: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-blue-600/20 text-blue-400',
|
||||
archived: 'bg-gray-600/20 text-gray-400',
|
||||
};
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
||||
blocked: 'bg-error/20 text-error',
|
||||
done: 'bg-success/20 text-success',
|
||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
||||
};
|
||||
|
||||
interface TabButtonProps {
|
||||
id: Tab;
|
||||
label: string;
|
||||
activeTab: Tab;
|
||||
onClick: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(id)}
|
||||
className={cn(
|
||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
||||
activeTab === id
|
||||
? 'border-text-primary text-text-primary'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailPage(): ReactElement {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setError('Project id is missing.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
void Promise.all([
|
||||
api<Project>('/api/projects/' + id),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([loadedProject, allMissions, loadedTasks]) => {
|
||||
if (cancelled) return;
|
||||
setProject(loadedProject);
|
||||
setMissions(allMissions.filter((mission) => mission.projectId === id));
|
||||
setTasks(loadedTasks);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load project.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error ?? 'Project not found.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="mt-4 w-fit text-sm underline"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredTasks =
|
||||
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
||||
const prdContent = getPrdContent(project);
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="hover:text-text-secondary"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={String(tasks.filter((task) => task.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
||||
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
label={tab.label}
|
||||
activeTab={activeTab}
|
||||
onClick={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'tasks' ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
|
||||
|
||||
{activeTab === 'prd' && prdContent ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
<PrdViewer content={prdContent} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
project,
|
||||
missions,
|
||||
tasks,
|
||||
}: {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
}): ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2"
|
||||
>
|
||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{project.metadata && Object.keys(project.metadata).length > 0 ? (
|
||||
<section className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
||||
{JSON.stringify(project.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueClass?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
||||
<p className="text-xs text-text-muted">{label}</p>
|
||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPrdContent(project: Project): string | null {
|
||||
if (!project.metadata) return null;
|
||||
|
||||
const prd = project.metadata['prd'];
|
||||
if (typeof prd === 'string' && prd.trim().length > 0) {
|
||||
return prd;
|
||||
}
|
||||
|
||||
const prdContent = project.metadata['prdContent'];
|
||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) {
|
||||
return prdContent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { projectFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { ProjectsPage } from './projects';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
const routes: RouteObject[] = [
|
||||
{ path: '/projects', element: <ProjectsPage /> },
|
||||
{ path: '/projects/:id', element: <p>Project detail target</p> },
|
||||
];
|
||||
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/projects'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('ProjectsPage', () => {
|
||||
it('shows a visible loading state while the project request is in flight', async () => {
|
||||
const deferred = createDeferred<typeof projectFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading projects...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders project cards from the API and navigates to a project detail route on click', async () => {
|
||||
apiMock.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
const router = await renderProjectsPage();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects');
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Mosaic Stack'),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.state.location.pathname).toBe('/projects/project-1');
|
||||
expect(container.textContent).toContain('Project detail target');
|
||||
});
|
||||
|
||||
it('renders the empty state when the API returns no projects', async () => {
|
||||
apiMock.mockResolvedValueOnce([]);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
expect(container.textContent).toContain('No projects yet');
|
||||
expect(container.textContent).toContain(
|
||||
'Projects will appear here when created via the gateway API',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a visible alert when the projects request fails', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
export function ProjectsPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Project[]>('/api/projects')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setProjects(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load projects.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Projects will appear here when created via the gateway API
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={(selectedProject) => navigate(`/projects/${selectedProject.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { routes } from '@/routes';
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('resource route render blew up');
|
||||
}
|
||||
|
||||
function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === path) {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceRouteElementWithBoom(cloned.children, path);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('resource route error boundaries', () => {
|
||||
it.each(['/projects', '/projects/:id', '/tasks'])(
|
||||
'renders a recoverable fallback when %s throws during route render',
|
||||
async (path) => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path;
|
||||
const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), {
|
||||
initialEntries: [initialEntry],
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(container.textContent).not.toBe('');
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
interface ResourceRouteErrorBoundaryProps {
|
||||
message: string;
|
||||
href: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
|
||||
function ResourceRouteErrorBoundary({
|
||||
message,
|
||||
href,
|
||||
linkLabel,
|
||||
}: ResourceRouteErrorBoundaryProps): ReactElement {
|
||||
useRouteError();
|
||||
|
||||
return (
|
||||
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-3 p-8">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
<a href={href} className="text-sm underline">
|
||||
{linkLabel}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading projects."
|
||||
href="/projects"
|
||||
linkLabel="Reload projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectDetailRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading this project."
|
||||
href="/projects"
|
||||
linkLabel="Back to projects"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksRouteErrorBoundary(): ReactElement {
|
||||
return (
|
||||
<ResourceRouteErrorBoundary
|
||||
message="Something went wrong loading tasks."
|
||||
href="/tasks"
|
||||
linkLabel="Reload tasks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { taskFixtures } from './page-fixtures';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
import { TasksPage } from './tasks';
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
async function renderTasksPage(): Promise<void> {
|
||||
const routes: RouteObject[] = [{ path: '/tasks', element: <TasksPage /> }];
|
||||
const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] });
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<RouterProvider router={router} />);
|
||||
});
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('TasksPage', () => {
|
||||
it('shows a visible loading state before the tasks request settles', async () => {
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Loading tasks...');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => {
|
||||
apiMock.mockResolvedValueOnce(taskFixtures);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.textContent).toContain('Not Started');
|
||||
expect(container.textContent).toContain('In Progress');
|
||||
expect(container.textContent).toContain('Blocked');
|
||||
|
||||
const kanbanCard = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /projects/:id'),
|
||||
);
|
||||
expect(kanbanCard).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector('button[aria-label="Close task details"]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('List');
|
||||
});
|
||||
|
||||
const row = [...container.querySelectorAll('tr')].find((candidate) =>
|
||||
candidate.textContent?.includes('Route /tasks'),
|
||||
);
|
||||
expect(row).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
||||
});
|
||||
|
||||
it('renders a visible alert when the tasks request fails', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export function TasksPage(): ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Task[]>('/api/tasks')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setTasks(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load tasks.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('list')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'list'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView('kanban')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs transition-colors',
|
||||
view === 'kanban'
|
||||
? 'bg-surface-elevated text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
)}
|
||||
|
||||
{selectedTask ? (
|
||||
<TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import type { RouteObject } from 'react-router-dom';
|
||||
import { routes } from '@/routes';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { ChatPage } from '@/spa/pages/chat';
|
||||
import { ProjectDetailPage } from '@/spa/pages/project-detail';
|
||||
import { ProjectsPage } from '@/spa/pages/projects';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||
return routeObjects.flatMap((route) => [
|
||||
@@ -67,4 +70,28 @@ describe('SPA route table', () => {
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(ChatPage);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/projects', ProjectsPage],
|
||||
['/projects/:id', ProjectDetailPage],
|
||||
['/tasks', TasksPage],
|
||||
])(
|
||||
'renders a real authenticated page instead of the P1 placeholder at %s',
|
||||
(path, expectedType) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
expect(element.type).toBe(expectedType);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['/chat', '/projects', '/projects/:id', '/tasks'])(
|
||||
'defines an error boundary for %s',
|
||||
(path) => {
|
||||
const route = findRoute(routes, path);
|
||||
expect(route?.errorElement).toBeTruthy();
|
||||
expect(isValidElement(route?.errorElement)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': gatewayTarget,
|
||||
'/socket.io': { target: gatewayTarget, ws: true },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Administrator Guide
|
||||
|
||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
||||
|
||||
This book is the canonical home for installation, configuration, deployment, routine operations, security controls, incident response, and recovery. User workflows belong in [`USER-GUIDE/`](../USER-GUIDE/); implementation and contributor material belongs in [`DEVELOPER-GUIDE/`](../DEVELOPER-GUIDE/).
|
||||
|
||||
## Start here
|
||||
|
||||
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
|
||||
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
|
||||
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
|
||||
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines.
|
||||
- [Security index](security/README.md) — current SSO provider configuration.
|
||||
|
||||
## Chapter map
|
||||
|
||||
| Chapter | Scope | Status |
|
||||
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
|
||||
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
|
||||
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
|
||||
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. |
|
||||
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
|
||||
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
|
||||
|
||||
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
|
||||
|
||||
## Evidence — not current operator guidance
|
||||
|
||||
- [`P8-003 performance report`](../reports/qa/p8-003-performance-optimization.md) — historical performance evidence; implementation alignment is partial, and production metrics remain unverified. It is not an operational SLO or runbook.
|
||||
|
||||
## Migration backlog — not current operator guidance
|
||||
|
||||
These are source candidates, not verified runbooks:
|
||||
|
||||
- `_old_structure/guides/admin-guide.md` — quarantined historical source; verify claims before promotion.
|
||||
- `_old_structure/guides/deployment.md` — quarantined historical source; deployment commands and assumptions remain held.
|
||||
- See the [documentation catalog](../reports/documentation/2026-08-10-docs-catalog-audit.md) for file-level dispositions.
|
||||
|
||||
Do not treat a migration candidate as current until its commands, paths, permissions, and safety status are checked against source and tests.
|
||||
|
||||
## Authoring boundary
|
||||
|
||||
New administrator documentation belongs under one of the chapter directories above. Operationally sensitive pages must identify prerequisites, ownership, source-of-truth dependencies, and whether any procedure is current, illustrative, held, or non-operative.
|
||||
|
||||
## Related
|
||||
|
||||
- [[README|Documentation contract]]
|
||||
- [[PRD|Product requirements]]
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Administrator Operations
|
||||
|
||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
||||
|
||||
## Current procedures
|
||||
|
||||
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery.
|
||||
|
||||
## Held procedures
|
||||
|
||||
- [Mos connector lease operations](mos-connector-lease-operations.md) — non-operative M1 outline while the gateway policy remains deny-all and no connector is activated.
|
||||
|
||||
A held page is architecture and readiness context, not command authority. PostgreSQL, federated, bare-metal, Compose, Gateway/Web activation, and migration-runner procedures remain outside the current local route unless a later page explicitly removes the hold with verified evidence.
|
||||
|
||||
## Related
|
||||
|
||||
- [[ADMIN-GUIDE/README|Administrator guide]]
|
||||
- [[ADMIN-GUIDE/security/README|Administrator security]]
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
> **Status:** Held / non-operative.
|
||||
> **Operational authority:** None. This page is a future runbook outline, not a current command, endpoint, migration, or activation procedure.
|
||||
> **Hold condition:** The gateway's `DenyConnectorLeasePolicy` remains the default policy and rejects every lease and grant operation. No connector is activated by this page.
|
||||
|
||||
## Current state
|
||||
|
||||
M1 provides an implemented lease, fencing, audit, and gateway policy boundary. It does not currently provide an operator-facing lease endpoint, activate a connector, cut over a channel, or connect an existing runtime provider to `ConnectorExecutionContext`. The default gateway module is deny-all, so operators must not treat the schema or internal service as an available lease-control surface.
|
||||
|
||||
There is no current operator command sequence to acquire, renew, take over, release, or grant connector authority. Do not attempt to operate the held procedure through direct database writes or by bypassing the gateway policy. Any future activation requires a separately approved server-side policy, concrete adapter, downstream fencing design, and an updated operational runbook.
|
||||
|
||||
## Held future procedure — not current command authority
|
||||
|
||||
The following records the intended shape of a later runbook. It is deliberately non-operative while deny-all remains:
|
||||
|
||||
### Events and evidence to monitor later
|
||||
|
||||
If an authorized policy and adapter are activated in a future work package, correlation IDs may be used to inspect `connector_lease_audit_log` events:
|
||||
|
||||
| Event | Meaning |
|
||||
| ---------- | ---------------------------------------------------------------------------------- |
|
||||
| `acquire` | First holder inserted for an unused binding |
|
||||
| `renew` | Current holder heartbeat extended the TTL |
|
||||
| `takeover` | Authorized compare-and-swap replaced the holder and incremented epoch |
|
||||
| `release` | Current holder explicitly relinquished authority |
|
||||
| `expiry` | An expired current lease was observed |
|
||||
| `reject` | Policy, compare-and-swap, expiry, scope, or fencing validation denied an operation |
|
||||
|
||||
Audit records are metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output. The current schema does not independently enforce append-only storage; database-level protection remains a future hardening requirement.
|
||||
|
||||
### Held incident-review outline
|
||||
|
||||
For a future suspected duplicate or stale connector effect, an approved operator procedure would:
|
||||
|
||||
1. correlate the attempted operation with its `reject`, `takeover`, or `expiry` event;
|
||||
2. compare the durable row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context;
|
||||
3. treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative rather than retrying it as the old holder;
|
||||
4. use only the authorized takeover path with the observed expected epoch, never ordinary acquire, for an expired or released row; and
|
||||
5. preserve evidence without assuming lease fencing provides exactly-once replay safety if an external effect may already have occurred.
|
||||
|
||||
These are held review requirements, not instructions to bypass the current deny-all policy.
|
||||
|
||||
### Held migration and rollback notes
|
||||
|
||||
The checked-in `0016_salty_morlocks.sql` artifact is additive: it creates the lease and audit tables and indexes without changing existing authorization/session tables. A future database rollout would still require the repository's approved migration, backup, verification, and rollback controls. Application rollback would leave additive lease/audit tables in place; dropping them would destroy evidence and is not an automatic rollback step.
|
||||
|
||||
This page does not authorize running migrations, connecting to PostgreSQL, initializing PGlite, or starting a connector. Those activities remain outside this held procedure and subject to repository/runtime gates.
|
||||
|
||||
### Held security prerequisites
|
||||
|
||||
Before this page could become operative, the activation work would need to demonstrate at least:
|
||||
|
||||
- tenant authority derived from authenticated gateway context, not connector request fields;
|
||||
- authorized policy decisions over normalized identity and scopes, with policy TTL handling based on the requested input and coordinator hard caps still applied;
|
||||
- explicit takeover authorization and expected-epoch compare-and-swap;
|
||||
- application-boundary normalization plus any separately approved database constraints or protections;
|
||||
- validation and rejection audit before adapter side effects;
|
||||
- a concrete adapter that consumes and propagates the normalized epoch/context; and
|
||||
- existing authorization and exact-action approval controls remaining in force.
|
||||
|
||||
M1 currently satisfies the boundary contract and default-deny posture, not these activation prerequisites.
|
||||
|
||||
## Explicit non-goals while held
|
||||
|
||||
This page does not authorize or claim:
|
||||
|
||||
1. lease administration from the dashboard, CLI, HTTP, SQL console, or a connector;
|
||||
2. production connector or channel activation;
|
||||
3. exactly-once delivery, side-effect journaling, checkpoint/handoff recovery, or replay safety;
|
||||
4. automatic failover, rollback, or stale-effect recovery across Claude, Pi, Codex, Matrix, tmux, or provider sessions; or
|
||||
5. database-enforced audit immutability or database-enforced application normalization.
|
||||
|
||||
The page may be promoted to an operative runbook only after deny-all is intentionally replaced, concrete adapters are reviewed, activation evidence exists, and this hold is explicitly removed by the owning work package.
|
||||
|
||||
## Related contract
|
||||
|
||||
- [M1 logical identity and fencing decision](../../DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md)
|
||||
- [MOS-PORT requirements](../../PRD.md#mos-runtime-portability-workstream-mos-port)
|
||||
@@ -0,0 +1,248 @@
|
||||
# Upgrade safety and recovery
|
||||
|
||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||
> configuration. This is a filesystem and CLI runbook; it does not activate a
|
||||
> service or connect to a database.
|
||||
|
||||
This page covers the supported upgrade, rollback, health, and recovery checks for
|
||||
Mosaic framework configuration under `MOSAIC_HOME`. It deliberately does not
|
||||
turn the repository's deployment or PostgreSQL material into an operative
|
||||
procedure.
|
||||
|
||||
## Support boundary
|
||||
|
||||
Use this runbook only when all of the following are true:
|
||||
|
||||
- `mosaic` resolves to the installed Mosaic CLI (`command -v mosaic`).
|
||||
- The active application configuration is the local tier: `tier: "local"`,
|
||||
`storage.type: "pglite"`, and `queue.type: "local"`.
|
||||
- `DATABASE_URL` is unset. An inherited PostgreSQL DSN is outside this route and
|
||||
must be removed before continuing.
|
||||
- `MOSAIC_STORAGE_TIER` is unset or `local`; standalone and federated overrides
|
||||
are outside this route.
|
||||
- No Gateway, Web, Compose, or other service activation is required.
|
||||
|
||||
The following routes are **held/non-operative** in this guide:
|
||||
|
||||
| Route or operation | Status |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PostgreSQL-backed `standalone` storage | Held; do not activate or probe it here. |
|
||||
| Federated storage and peers | Held; do not activate or probe it here. |
|
||||
| Bare-metal deployment | Held; no service installation or lifecycle action is authorized. |
|
||||
| Gateway/Web activation or HTTP health checks | Held; `/health`, `/health/ready`, and `mosaic gateway ...` are not evidence for this route. |
|
||||
| Compose startup | Held; do not start a Compose profile or the full stack. |
|
||||
| Migration runners and tier migration | Held; do not run `mosaic-db-migrator`, `pnpm --filter @mosaicstack/db db:migrate`, `mosaic storage migrate`, or `mosaic storage migrate-tier`. |
|
||||
|
||||
A command being present in the CLI does not make a held route operative.
|
||||
|
||||
## Storage and path terminology
|
||||
|
||||
Keep these locations separate:
|
||||
|
||||
- **`MOSAIC_HOME`** — the framework/operator configuration directory. It
|
||||
defaults to `~/.config/mosaic` and can be overridden with `MOSAIC_HOME`.
|
||||
- **PGlite data** — the local, in-process database. The checked-in local config
|
||||
uses `.mosaic/storage-pglite` as its storage `dataDir`; `.mosaic/queue` is the
|
||||
local queue directory. These are project data, not framework configuration.
|
||||
- **Durable upgrade snapshots** — operator-file snapshots stored under
|
||||
`${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/backups/`. They are outside
|
||||
`MOSAIC_HOME` and do not contain a PostgreSQL dump.
|
||||
|
||||
The configuration tier is named `local`; the storage CLI reports the backend as
|
||||
`pglite`. PGlite is not PostgreSQL and does not require a PostgreSQL server.
|
||||
Local PGlite schema setup is adapter-owned; this page does not authorize a
|
||||
separate migration runner.
|
||||
|
||||
## Pre-upgrade health gate
|
||||
|
||||
Run these checks from a shell that has no inherited database DSN:
|
||||
|
||||
```bash
|
||||
command -v mosaic
|
||||
mosaic --version
|
||||
mosaic config path
|
||||
test -d "$(mosaic config path)"
|
||||
|
||||
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage tier show
|
||||
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage status
|
||||
mosaic restore --list
|
||||
```
|
||||
|
||||
For the supported route, the storage checks should report the `pglite` backend
|
||||
and say that no network check is needed. `mosaic storage status` reports
|
||||
`PGLITE_DATA_DIR` when that variable is set; otherwise it reports the CLI's
|
||||
`:memory:` fallback. That output is an inspection of the CLI environment, not a
|
||||
claim that PGlite contents are healthy or durable.
|
||||
|
||||
If `DATABASE_URL` is set, stop. Do not point it at a local PostgreSQL instance
|
||||
to make the check pass. If the active configuration is not local/PGlite, stop;
|
||||
no operative route is documented here.
|
||||
|
||||
The supported health gate is intentionally limited to CLI resolution, framework
|
||||
configuration path, local storage selection, and available upgrade snapshots.
|
||||
It does not prove Gateway/Web readiness, provider connectivity, queue health, or
|
||||
PGlite data integrity.
|
||||
|
||||
## Safe upgrade procedure
|
||||
|
||||
1. **Record the baseline.** Save the output of `mosaic --version`,
|
||||
`mosaic config path`, and `mosaic restore --list`. Do not copy secrets into a
|
||||
ticket or report.
|
||||
2. **Check for updates without installing them:**
|
||||
|
||||
```bash
|
||||
mosaic update --check
|
||||
```
|
||||
|
||||
Exit status `0` means no update was reported; status `2` means an update is
|
||||
available. Other failures are not a successful health result. The check is a
|
||||
package-registry check and does not start Mosaic services or access storage.
|
||||
|
||||
3. **Run the installed-CLI upgrade when approved:**
|
||||
|
||||
```bash
|
||||
mosaic update
|
||||
```
|
||||
|
||||
The command updates the installed `@mosaicstack/*` packages through npm and,
|
||||
when the framework package changed or framework drift is detected, re-seeds
|
||||
framework files in keep mode. Leave the default re-seed enabled. Do not use
|
||||
`--relaunch` in this runbook: that option can restart durable fleet agents
|
||||
and is outside the supported no-activation route. `--no-reseed` is also not a
|
||||
normal upgrade path because it intentionally leaves framework files stale.
|
||||
|
||||
4. **Repeat the health gate.** Confirm the CLI version, the same
|
||||
`MOSAIC_HOME`, the local/PGlite storage selection, and the snapshot listing.
|
||||
A successful framework upgrade must not be used as evidence that a held
|
||||
Gateway, Web, PostgreSQL, or federated route is ready.
|
||||
|
||||
Do not replace this procedure with a direct edit of `~/.config/mosaic`, a
|
||||
repository `tools/install.sh` invocation, a Compose startup, or a migration
|
||||
command. The supported operator entry point for this page is the installed
|
||||
`mosaic update` command.
|
||||
|
||||
## What an upgrade protects
|
||||
|
||||
For an existing keep-mode installation, the framework installer takes two
|
||||
separate snapshots before the file sync:
|
||||
|
||||
1. An ephemeral whole-directory snapshot under `${TMPDIR:-/tmp}/` is used to
|
||||
restore the previous framework directory if the sync is interrupted or
|
||||
fails. If that restore cannot complete, the installer prints the retained
|
||||
temporary snapshot path for manual recovery.
|
||||
2. A durable snapshot captures the existing operator-owned files before the
|
||||
upgrade at:
|
||||
|
||||
```text
|
||||
${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/backups/pre-update-<UTC timestamp>/
|
||||
```
|
||||
|
||||
Snapshot directories are mode `0700`; captured files are mode `0600`.
|
||||
Retention is five snapshots by default and can be changed with
|
||||
`MOSAIC_BACKUP_RETENTION`. A durable snapshot failure is a warning and does
|
||||
not replace the manifest and crash-rollback protections.
|
||||
|
||||
After the keep-mode sync, the installer compares each captured operator file
|
||||
with its target. If a file was changed or removed unexpectedly, it restores the
|
||||
snapshot copy and emits a warning. A symlinked parent is not followed; that
|
||||
case is left for manual recovery from the snapshot path.
|
||||
|
||||
Keep mode is manifest-driven and fail-safe for operator paths, but it does not
|
||||
mean every file under `MOSAIC_HOME` is user-owned. The framework contract files
|
||||
`CONSTITUTION.md`, `AGENTS.md`, and `STANDARDS.md` are refreshed by upgrades;
|
||||
keep local policy in the supported local overlays rather than editing those
|
||||
contract files as rollback data. A durable snapshot is for operator-owned
|
||||
configuration, not for the installed npm package, framework-owned contracts, or
|
||||
PGlite data.
|
||||
|
||||
## Rollback and recovery
|
||||
|
||||
### If the upgrade fails or is interrupted
|
||||
|
||||
The framework sync attempts an automatic rollback from its ephemeral snapshot.
|
||||
Do not delete `MOSAIC_HOME`, the PGlite data directory, or the temporary snapshot
|
||||
named in the error. Preserve the command output, then run the health gate.
|
||||
|
||||
If the installed framework reports that the automatic restore did not complete,
|
||||
use the durable snapshot procedure below. The durable snapshot is the recovery
|
||||
pointer that survives a successful upgrade and the removal of the temporary
|
||||
snapshot.
|
||||
|
||||
### Restore operator configuration from a durable snapshot
|
||||
|
||||
`mosaic restore` is confirmation-gated and reports counts and relative paths,
|
||||
not file contents. First list snapshots, then preview the selected timestamp:
|
||||
|
||||
```bash
|
||||
mosaic restore --list
|
||||
mosaic restore --from <UTC-timestamp> --dry-run
|
||||
```
|
||||
|
||||
If the preview is correct, run the interactive restore:
|
||||
|
||||
```bash
|
||||
mosaic restore --from <UTC-timestamp>
|
||||
```
|
||||
|
||||
Use `--yes` only when the overwrite has been explicitly approved:
|
||||
|
||||
```bash
|
||||
mosaic restore --from <UTC-timestamp> --yes
|
||||
```
|
||||
|
||||
The timestamp is the value printed by `mosaic restore --list`; the command also
|
||||
accepts the full `pre-update-<timestamp>` name. For a non-default configuration
|
||||
home, pass the same target explicitly:
|
||||
|
||||
```bash
|
||||
mosaic restore --mosaic-home "$MOSAIC_HOME" --from <UTC-timestamp>
|
||||
```
|
||||
|
||||
After restoring, repeat the health gate. Restore writes only the operator
|
||||
surface represented by that snapshot. It does not downgrade the installed CLI,
|
||||
restore framework-owned contract files, restore `.mosaic/storage-pglite`, or
|
||||
run any migration.
|
||||
|
||||
### If local PGlite data is missing or corrupt
|
||||
|
||||
Do not use a framework snapshot as a database backup. Do not run a migration
|
||||
runner, start PostgreSQL, start Compose, or activate Gateway/Web to investigate.
|
||||
The current CLI does not provide a wired PGlite export/import operation;
|
||||
`mosaic storage export` and `mosaic storage import` only print direct-copy
|
||||
guidance. Preserve the configured PGlite data directory and route data
|
||||
restoration through the held data-layer procedure rather than improvising a
|
||||
recursive delete or copy.
|
||||
|
||||
### Recovery decision tree
|
||||
|
||||
- **CLI or framework path is wrong:** stop, verify `command -v mosaic`,
|
||||
`mosaic --version`, `MOSAIC_HOME`, and `mosaic config path`; do not activate a
|
||||
held service route.
|
||||
- **Upgrade failed during framework sync:** use the installer's automatic
|
||||
rollback first; if it reports incomplete recovery, list and preview the
|
||||
durable snapshot, then restore it interactively.
|
||||
- **An operator file changed after a reported-successful upgrade:** select the
|
||||
pre-upgrade snapshot with `mosaic restore --list`, preview it with `--dry-run`,
|
||||
and restore only after reviewing the relative-path plan.
|
||||
- **PGlite data is affected:** preserve the data directory and stop at the
|
||||
boundary described above. Framework rollback cannot recover database rows.
|
||||
- **Output requests PostgreSQL, federated, bare-metal, Compose, Gateway/Web, or
|
||||
a migration runner:** stop; that is a held/non-operative route, not a next
|
||||
command for this runbook.
|
||||
|
||||
## Post-recovery verification
|
||||
|
||||
Run the non-mutating checks again:
|
||||
|
||||
```bash
|
||||
mosaic --version
|
||||
mosaic config path
|
||||
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage tier show
|
||||
env -u DATABASE_URL -u MOSAIC_STORAGE_TIER mosaic storage status
|
||||
mosaic restore --list
|
||||
```
|
||||
|
||||
A green result here means the installed CLI resolves, the framework path is
|
||||
present, the CLI selects local PGlite without a network probe, and snapshots can
|
||||
be enumerated. It does not certify database contents or any held deployment
|
||||
route.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Security
|
||||
|
||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||
|
||||
This chapter will contain authentication, authorization, SSO, secrets, RBAC, and security-control guidance for administrators.
|
||||
|
||||
## Planned pages
|
||||
|
||||
- [`sso-providers.md`](sso-providers.md) — current provider configuration, discovery, callbacks, and failure modes.
|
||||
- [`discord-ingress.md`](discord-ingress.md) — current Discord service authentication, allowlists, bindings, roles, replay, and failure controls.
|
||||
- `secrets.md` — document general secret handling after source/configuration verification.
|
||||
- `rbac.md` — document roles and permissions from the canonical implementation.
|
||||
|
||||
The current SSO page reflects dynamic provider discovery. The Discord page documents only the verified Discord compatibility boundary; it does not claim Telegram or Matrix parity. Do not revive retired root documents or add frontend feature flags that the web flow does not consume.
|
||||
|
||||
## Related
|
||||
|
||||
- [`Administrator guide`](../README.md)
|
||||
- [`API documentation`](../../API/README.md)
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
@@ -0,0 +1,137 @@
|
||||
# Discord ingress security
|
||||
|
||||
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
||||
>
|
||||
> **Last verified:** 2026-08-10 against the Discord plugin, gateway ingress/authentication code, and focused tests linked in [Evidence](#evidence).
|
||||
>
|
||||
> **Audience:** Administrators provisioning the Discord remote-control boundary.
|
||||
|
||||
This page documents the security boundary that exists today. It is not a deployment recipe for Telegram or Matrix, and it does not turn the gateway's lifecycle plugin list into a universal channel registry.
|
||||
|
||||
## Security model
|
||||
|
||||
Discord ingress has two current layers:
|
||||
|
||||
1. **Native Discord admission** in `@mosaicstack/discord-plugin` applies guild/channel/user allowlists, pairing, role, rate, and thread rules before a thread is created or a message is dispatched.
|
||||
2. **Gateway compatibility admission** authenticates the Discord Socket.IO service, verifies the signed envelope again, re-checks the allowlists and binding, validates the conversation route, rejects replayed native message IDs, and then dispatches the message to the trusted agent configuration.
|
||||
|
||||
The current gateway namespace is `/chat`. The Discord plugin connects with a Socket.IO handshake value named `discordServiceToken`; this is distinct from the environment variable name `DISCORD_SERVICE_TOKEN` that supplies the value to the plugin and gateway.
|
||||
|
||||
### Admission and authorization order
|
||||
|
||||
For an inbound guild message, the current implementation:
|
||||
|
||||
1. Ignores bot-authored messages and messages without a guild. Discord DMs are therefore not handled by this ingress path, even though the client requests a direct-message intent.
|
||||
2. Uses the configured thread parent as the authorization channel for a thread. A normal Discord category parent is never substituted for a text channel.
|
||||
3. Requires the guild, authorization channel, and user to appear in their respective allowlists.
|
||||
4. Resolves a configuration-owned binding and paired user. `viewer` cannot send a turn. Ordinary `send` requires `operator` or `admin`; `approve` and `stop` require `admin`.
|
||||
5. Applies the message and mention-thread rate limits before any thread creation or gateway dispatch.
|
||||
6. Derives the route from the binding's logical-agent instance and the response channel/thread. The route does not accept a provider, model, harness, process, or runtime-session selector from Discord.
|
||||
7. Creates or reuses a thread only after the checks above pass.
|
||||
|
||||
The gateway then verifies the HMAC-SHA-256 envelope with `DISCORD_SERVICE_TOKEN`, re-applies the allowlists and binding/role check, requires the conversation ID to match the bound logical agent and channel/thread, and claims the native Discord message ID in a bounded replay cache. The default replay cache is in-process, retains IDs for 15 minutes, and is bounded at 10,000 entries; it is not a durable inbox.
|
||||
|
||||
For ordinary chat, the gateway attempts persistence and dispatch using `DISCORD_SERVICE_USER_ID`; `DISCORD_SERVICE_TENANT_ID` is used when configured and otherwise ordinary chat falls back to the service user ID as its tenant. The Discord external route is not a UUID, while persisted conversation IDs are UUIDs; no current route-to-UUID mapping proves that ordinary Discord persistence succeeds. The gateway may continue dispatch after a persistence/binding failure, so successful live delivery is not durable-history evidence.
|
||||
|
||||
Privileged approval and stop additionally require a configured tenant, a paired `mosaicUserId`, and a previously enrolled durable session. The durable session's logical agent must match the binding before approval or stop is accepted. The approval is consumed once against the exact runtime target; the Discord service account is not substituted for the approving paired user.
|
||||
|
||||
The trusted `agentConfigId` in each binding is resolved by the gateway. Its provisioned agent name must exactly equal `instanceId`. Discord cannot choose an arbitrary agent, provider, or model in the message payload, and Discord ingress does not use the general routing engine for a new session.
|
||||
|
||||
## Required configuration
|
||||
|
||||
The gateway's current plugin factory is in [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts). When `DISCORD_BOT_TOKEN` is present, the following Discord values are required or validated as shown:
|
||||
|
||||
| Name | Required/current behavior |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DISCORD_BOT_TOKEN` | Enables the Discord plugin and supplies the Discord bot credential. |
|
||||
| `DISCORD_SERVICE_TOKEN` | Required when the bot is enabled. Authenticates the Socket.IO service handshake and signs/verifies ingress envelopes. Treat as a high-entropy secret. |
|
||||
| `DISCORD_SERVICE_USER_ID` | Required when the bot is enabled. Provisioned Mosaic service principal used for ordinary Discord dispatch and attempted persistence; durable history is not guaranteed. |
|
||||
| `DISCORD_SERVICE_TENANT_ID` | Not required to start ordinary Discord chat, but required for the `/approve` and `/stop <approval>` control path. Use the provisioned tenant for the service boundary. |
|
||||
| `DISCORD_GATEWAY_URL` | Base gateway URL. The plugin connects to `${DISCORD_GATEWAY_URL}/chat`; the gateway factory default is `http://localhost:14242`. |
|
||||
| `DISCORD_GUILD_ID` | Optional guild ID used only by the current project-channel provisioning helper. It is not the message authorization allowlist. |
|
||||
| `DISCORD_ALLOWED_GUILD_IDS` | Required, comma-separated guild IDs. Empty or missing values fail closed during plugin creation. |
|
||||
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required, comma-separated parent text-channel IDs. Thread messages are checked against their configured parent. |
|
||||
| `DISCORD_ALLOWED_USER_IDS` | Required, comma-separated Discord user IDs. This allowlist is checked in addition to `pairedUsers`. |
|
||||
| `DISCORD_INTERACTION_BINDINGS` | Required, non-empty JSON array of configuration-owned bindings. Malformed or empty data fails plugin creation. |
|
||||
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `30` authorized turns per guild/channel/user window. Zero, negative, and non-integer values are rejected. |
|
||||
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; default is `5` mention-triggered thread routes per guild/channel/user window. Invalid values are rejected. |
|
||||
|
||||
`MOSAIC_AGENT_NAME` and `MOSAIC_AGENT_CONFIG_ID` are not substitutes for a Discord binding. The current Discord binding uses `instanceId` and `agentConfigId` inside `DISCORD_INTERACTION_BINDINGS`; do not invent a different environment-based routing contract.
|
||||
|
||||
### Binding shape
|
||||
|
||||
Use placeholders for identifiers and keep credentials out of the JSON:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"instanceId": "interaction-agent",
|
||||
"agentConfigId": "provisioned-agent-config-id",
|
||||
"guildId": "guild-id",
|
||||
"channelId": "parent-channel-id",
|
||||
"pairedUsers": {
|
||||
"discord-user-id": {
|
||||
"role": "operator",
|
||||
"mosaicUserId": "provisioned-mosaic-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Each binding requires `instanceId`, `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. Pairing roles are `viewer`, `operator`, and `admin`. A role-only pairing remains accepted for ordinary non-privileged compatibility, but it has no `mosaicUserId` and cannot authorize the privileged approval/stop path. The guild and parent channel must also be present in their allowlists.
|
||||
|
||||
The bot needs permission to view and send messages in the configured channels and to create and send public threads. A category parent is not an authorization boundary. A thread inherits authorization only from its configured parent text channel.
|
||||
|
||||
### Secret handling
|
||||
|
||||
Supply `DISCORD_BOT_TOKEN` and `DISCORD_SERVICE_TOKEN` through the approved runtime secret mechanism. Do not commit them, put them in binding JSON, or pass them on a command line.
|
||||
|
||||
The current `mosaic gateway config --set KEY=VALUE` implementation writes the gateway `.env` file and prints the value in its confirmation; its mask list does not include `DISCORD_SERVICE_TOKEN`. Do **not** use that command for the service token. `mosaic gateway config --edit` exists for local configuration, but production secret provisioning must remain outside the repository and follow the approved secret path.
|
||||
|
||||
## Applying configuration safely
|
||||
|
||||
These are the current CLI commands exposed by `@mosaicstack/mosaic`; they manage the gateway daemon and do not constitute a Discord protocol:
|
||||
|
||||
```bash
|
||||
mosaic gateway install
|
||||
mosaic gateway config --edit
|
||||
mosaic gateway status
|
||||
mosaic gateway verify
|
||||
mosaic gateway restart
|
||||
mosaic gateway logs --lines 50
|
||||
```
|
||||
|
||||
The daemon reads its environment from `~/.config/mosaic/gateway/.env` by default; `MOSAIC_GATEWAY_HOME` can change that home. `mosaic gateway config --set KEY=VALUE` and `--unset KEY` are also implemented for non-secret values. After changing Discord configuration, restart the gateway so the plugin factory is rebuilt. `mosaic gateway status` and `mosaic gateway verify` check the gateway daemon/health surfaces; the current lifecycle host does not expose a channel-specific `healthAll` command, so a green gateway check alone is not proof that Discord is connected.
|
||||
|
||||
## Failure and abuse behavior
|
||||
|
||||
| Condition | Current result |
|
||||
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Missing service token/user, allowlist, or interaction bindings when Discord is enabled | Gateway plugin creation fails rather than enabling an unconfigured remote-control surface. |
|
||||
| Invalid optional rate limit | Plugin creation fails; values must be positive integers. |
|
||||
| Unallowlisted guild/channel/user, unpaired user, or insufficient role | Message is ignored before thread creation and gateway dispatch. |
|
||||
| Mentioned message cannot create or fetch its requested thread | The message is not dispatched because its response target cannot be honored. |
|
||||
| Invalid HMAC, malformed envelope, route mismatch, wrong binding, or replayed native message ID | Gateway rejects the ingress without dispatch. |
|
||||
| Unsafe attachment metadata or URL | Gateway rejects the message before acknowledgement/dispatch. Current bounds include at most 10 attachments, HTTPS URLs without credentials, query strings, or fragments, and bounded ID/name/URL/metadata lengths. |
|
||||
| Missing `DISCORD_SERVICE_TENANT_ID` for approval/stop | The privileged control handler returns without creating or consuming an approval. |
|
||||
| Agent configuration ID does not resolve or its name differs from `instanceId` | Gateway refuses to create the Discord-bound session. |
|
||||
| External route cannot be persisted as a UUID conversation | Gateway may still dispatch live output; durable history and restart/resume continuity are not guaranteed and must not be inferred from delivery. |
|
||||
|
||||
## Explicitly not current
|
||||
|
||||
- **Telegram:** `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GATEWAY_URL` can instantiate the raw legacy Telegram plugin, but that plugin does not use the shared channel DTOs, Discord-style service authentication, allowlists, pairing, route validation, or a tested gateway security boundary. Do not treat these variables as a secured Telegram equivalent of the Discord configuration above.
|
||||
- **Matrix:** No current gateway channel adapter, binding, authentication path, or focused channel test establishes Matrix ingress. Matrix-related fleet/runtime code is not evidence of a Matrix channel deployment procedure.
|
||||
- **Shared registry parity:** The current gateway `PLUGIN_REGISTRY` hosts lifecycle wrappers (`name`, `start`, `stop`, and optional project provisioning). It does not expose a universal channel health/ingress/egress registry. That is follow-up work, not an administrator capability today.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — Discord allowlists, bindings, roles, thread routing, signed envelope, typed ingress/egress, limits, retry, and health.
|
||||
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — authorization ordering, thread behavior, attachments, stable routes, egress, rate limits, and health.
|
||||
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` authentication, envelope validation, replay, trusted agent selection, ordinary dispatch, approval, and stop.
|
||||
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — timing-safe service-token and BetterAuth session validation.
|
||||
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, attachment, binding, approval, stop, and logical-agent checks.
|
||||
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — control-flow evidence with explicit durable-session pre-enrollment, not proof of ordinary Discord persistence.
|
||||
- [`packages/mosaic/src/commands/gateway.ts`](../../../packages/mosaic/src/commands/gateway.ts) and [`gateway/config.ts`](../../../packages/mosaic/src/commands/gateway/config.ts) — verified gateway CLI command names and configuration behavior.
|
||||
- [Channel protocol architecture](../../DEVELOPER-GUIDE/architecture/channel-protocol.md) — canonical shared-contract and parity boundary.
|
||||
- [Discord conversation workflow](../../USER-GUIDE/workflows/discord-conversations.md) — end-user behavior.
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: SSO Providers
|
||||
type: runbook
|
||||
audience: admin
|
||||
status: current
|
||||
source_of_truth: false
|
||||
---
|
||||
|
||||
# SSO Providers
|
||||
|
||||
Configure optional enterprise single sign-on for Mosaic Stack through Better Auth's generic OAuth integration. The gateway owns provider configuration and discovery; the web application renders only providers reported by the gateway.
|
||||
|
||||
> **Current behavior:** Authentik, WorkOS, and Keycloak are supported in the checked-in implementation. Authentik and WorkOS use OIDC. Keycloak supports OIDC and an optional direct SAML login URL. The web application does **not** read `NEXT_PUBLIC_WORKOS_ENABLED` or `NEXT_PUBLIC_KEYCLOAK_ENABLED`; provider buttons are discovered dynamically from the gateway.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before configuring a provider, establish these gateway settings:
|
||||
|
||||
- `BETTER_AUTH_URL` — the public base URL used to construct OAuth callback URLs.
|
||||
- `BETTER_AUTH_SECRET` — a strong secret for Better Auth sessions and tokens.
|
||||
- `GATEWAY_CORS_ORIGIN` — the web origin or comma-separated origins allowed by the gateway.
|
||||
|
||||
Keep client secrets and Better Auth secrets in the deployment secret store. Do not commit them to `.env` files or expose them to the web bundle.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
### Authentik OIDC
|
||||
|
||||
Set all three variables to enable Authentik:
|
||||
|
||||
```bash
|
||||
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
|
||||
AUTHENTIK_CLIENT_ID=...
|
||||
AUTHENTIK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
The implementation derives OIDC discovery and endpoint URLs from `AUTHENTIK_ISSUER`. An optional team-claim label can be exposed in provider discovery:
|
||||
|
||||
```bash
|
||||
AUTHENTIK_TEAM_SYNC_CLAIM=groups
|
||||
```
|
||||
|
||||
The default reported claim is `groups`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
|
||||
|
||||
Register this redirect URI with the Authentik application:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik
|
||||
```
|
||||
|
||||
### WorkOS OIDC
|
||||
|
||||
Set all three variables to enable WorkOS:
|
||||
|
||||
```bash
|
||||
WORKOS_ISSUER=https://your-company.authkit.app
|
||||
WORKOS_CLIENT_ID=client_...
|
||||
WORKOS_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
Use the WorkOS AuthKit issuer or custom authentication domain, not a raw WorkOS REST API hostname. Mosaic derives the OIDC discovery URL by appending `/.well-known/openid-configuration` to the issuer. WorkOS uses PKCE and issuer validation in the current auth configuration.
|
||||
|
||||
An optional team-claim label can be exposed in provider discovery:
|
||||
|
||||
```bash
|
||||
WORKOS_TEAM_SYNC_CLAIM=organization_id
|
||||
```
|
||||
|
||||
The default reported claim is `organization_id`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
|
||||
|
||||
Register this redirect URI with the WorkOS application:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
|
||||
```
|
||||
|
||||
### Keycloak OIDC
|
||||
|
||||
Use either an explicit issuer or the URL-plus-realm form. The client ID and secret are required in both forms.
|
||||
|
||||
Explicit issuer:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_ISSUER=https://auth.example.com/realms/mosaic
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
Derived issuer:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_URL=https://auth.example.com
|
||||
KEYCLOAK_REALM=mosaic
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
`KEYCLOAK_ISSUER` takes precedence when both forms are present. Keycloak uses PKCE and issuer validation in the current auth configuration.
|
||||
|
||||
An optional team-claim label can be exposed in provider discovery:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_TEAM_SYNC_CLAIM=groups
|
||||
```
|
||||
|
||||
The default reported claim is `groups`. The discovery payload reports this claim; verify any downstream membership-sync behavior separately before treating it as an authorization guarantee.
|
||||
|
||||
Register this redirect URI with the Keycloak client:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
|
||||
```
|
||||
|
||||
### Keycloak direct SAML fallback
|
||||
|
||||
The current web flow supports a direct SAML link for Keycloak when `KEYCLOAK_SAML_LOGIN_URL` is configured:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_SAML_LOGIN_URL=https://auth.example.com/realms/mosaic/protocol/saml
|
||||
```
|
||||
|
||||
This creates a configured Keycloak provider with `loginMode: saml`. The web login button links directly to the supplied URL as `Continue with Keycloak (SAML)`; there is no Better Auth OIDC callback for this mode. The URL must be the provider's valid SAML launch URL for the deployment.
|
||||
|
||||
A SAML-only Keycloak configuration does not require the Keycloak OIDC client variables. Do not combine an incomplete OIDC variable set with SAML-only configuration: partial OIDC configuration is rejected during auth setup.
|
||||
|
||||
## Callback and discovery contract
|
||||
|
||||
Better Auth is mounted at `/api/auth`. OIDC callbacks use:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
|
||||
```
|
||||
|
||||
The gateway exposes provider discovery at:
|
||||
|
||||
```text
|
||||
GET /api/sso/providers
|
||||
```
|
||||
|
||||
The response includes `authentik`, `workos`, and `keycloak` records with:
|
||||
|
||||
- `configured` — whether a usable OIDC or Keycloak SAML configuration is present.
|
||||
- `protocols` — supported protocols for the provider record.
|
||||
- `loginMode` — `oidc`, `saml`, or `null`.
|
||||
- `callbackPath` — the OIDC callback path, or `null` for SAML-only mode.
|
||||
- `teamSync` — the configured/default claim label exposed to the UI.
|
||||
- `samlFallback` — whether a direct Keycloak SAML URL is configured.
|
||||
- `warnings` — partial OIDC configuration warnings when reported.
|
||||
|
||||
The web login page filters this response to configured providers. OIDC buttons use Better Auth's `signIn.oauth2` flow. A configured Keycloak SAML fallback is rendered as a direct link. No provider-specific `NEXT_PUBLIC_*_ENABLED` flag is required or consumed.
|
||||
|
||||
## Configuration procedure
|
||||
|
||||
1. Set `BETTER_AUTH_URL` to the public gateway URL that the identity provider can reach.
|
||||
2. Set `BETTER_AUTH_SECRET` and the correct `GATEWAY_CORS_ORIGIN` values.
|
||||
3. Choose one provider configuration above and set its complete required variable group.
|
||||
4. Register the exact OIDC callback URI with the identity provider, when using OIDC.
|
||||
5. Restart or redeploy the gateway so it loads the changed environment.
|
||||
6. Inspect discovery without exposing secrets:
|
||||
|
||||
```bash
|
||||
curl "$BETTER_AUTH_URL/api/sso/providers"
|
||||
```
|
||||
|
||||
7. Open the web login page and confirm that only configured providers are shown.
|
||||
8. Complete a sign-in and verify the callback returns to the configured application.
|
||||
|
||||
## Partial configuration and failure modes
|
||||
|
||||
Provider configuration is optional. If no provider variables are set, the gateway can run without SSO providers and the web login page renders no SSO section.
|
||||
|
||||
For Authentik and WorkOS, setting only part of the issuer/client ID/client secret group raises a configuration error. For Keycloak OIDC, the client ID, client secret, and either an explicit issuer or a complete URL-plus-realm pair are required. Empty or whitespace-only values are treated as unset.
|
||||
|
||||
Common failures:
|
||||
|
||||
| Symptom | Check |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| Provider does not appear on the login page | Query `/api/sso/providers`; verify the complete provider variable group is present in the gateway environment. |
|
||||
| OAuth callback is rejected | Compare the registered redirect URI character-for-character with `BETTER_AUTH_URL` and the provider callback path. |
|
||||
| Provider is shown but sign-in cannot start | Check `loginMode`, issuer discovery, client credentials, and gateway logs. |
|
||||
| Keycloak SAML button is absent | Set `KEYCLOAK_SAML_LOGIN_URL` to the provider's direct launch URL and reload the gateway. |
|
||||
| Startup/auth initialization reports missing variables | Remove the partial provider configuration or provide the complete required group. |
|
||||
| SSO succeeds but team membership is unexpected | Treat `teamSync.claim` as discovery metadata and verify the actual claim mapping and membership-sync implementation. |
|
||||
|
||||
Do not enable a provider by adding the obsolete `NEXT_PUBLIC_WORKOS_ENABLED` or `NEXT_PUBLIC_KEYCLOAK_ENABLED` variables. They are not read by the current web application.
|
||||
|
||||
## Related
|
||||
|
||||
- [Administrator guide](../README.md)
|
||||
- [Security chapter](README.md)
|
||||
- [API documentation index](../../API/README.md)
|
||||
- [Documentation atlas](../../README.md)
|
||||
@@ -0,0 +1,31 @@
|
||||
# API Documentation
|
||||
|
||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
||||
|
||||
This directory is the single API documentation boundary. `OPENAPI.yaml` will be the machine-readable contract, and `ENDPOINTS.md` will provide the human-readable endpoint, authentication, permission, and error index. Neither file exists here yet; do not describe this scaffold as a complete API reference.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
|
||||
- [Documentation sitemap](../SITEMAP.md) — current API transition status and authority-gated backlog.
|
||||
- [Documentation catalog audit](../reports/documentation/2026-08-10-docs-catalog-audit.md) — current API artifact inventory and migration evidence.
|
||||
|
||||
## Contract map
|
||||
|
||||
| Artifact | Purpose | Status |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `OPENAPI.yaml` | Canonical machine-readable HTTP/WebSocket API contract. | Planned; not present yet. |
|
||||
| `ENDPOINTS.md` | Human index for endpoint behavior, auth, permissions, and errors. | Planned; not present yet. |
|
||||
| [`../openapi-tess.yaml`](../openapi-tess.yaml) | Legacy Tess-scoped OpenAPI artifact with 17 paths. | Migration candidate; not the complete gateway contract. |
|
||||
|
||||
A contract migration must verify paths, schemas, authentication, permissions, error behavior, and generated/client references before the legacy artifact is retired. Keep scoped contracts explicitly labeled if they remain alongside the consolidated contract.
|
||||
|
||||
## Authoring boundary
|
||||
|
||||
New API contracts belong here. Use `OPENAPI.yaml` for machine-readable authority and `ENDPOINTS.md` for human-readable constraints that OpenAPI cannot fully express. Guide books may explain usage workflows, but must link back to this directory rather than copying endpoint definitions.
|
||||
|
||||
## Related
|
||||
|
||||
- [[README|Documentation contract]]
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
- [[ADMIN-GUIDE/security/README|SSO provider chapter]]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Developer Guide
|
||||
|
||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
||||
|
||||
This book is the canonical home for architecture, package and application guides, local development, testing, contribution workflow, and integration authoring. User-facing procedures belong in [`USER-GUIDE/`](../USER-GUIDE/); operator procedures belong in [`ADMIN-GUIDE/`](../ADMIN-GUIDE/); API contracts belong in [`API/`](../API/).
|
||||
|
||||
## Start here
|
||||
|
||||
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
|
||||
- [Architecture index](architecture/README.md) — current architecture chapter scaffold.
|
||||
- [Documentation audit](../reports/documentation/2026-08-10-docs-catalog-audit.md) — evidence-based migration inventory.
|
||||
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
|
||||
|
||||
## Chapter map
|
||||
|
||||
| Chapter | Scope | Status |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| [`architecture/`](architecture/README.md) | System model, components, data flow, security model, ADRs, and RFCs. | Partially migrated. |
|
||||
| `packages/` | Package- and application-level contracts and guides. | Scaffold only. |
|
||||
| `local-development/` | Safe local setup and development routes. | Scaffold only. |
|
||||
| `testing/` | Test strategy, verification, and quality gates. | Lease-broker verification boundary is current. |
|
||||
| `contributing/` | Contribution, review, and delivery workflow. | Scaffold only. |
|
||||
| `integrations/` | Plugin, provider, and adapter authoring. | Channel-adapter authoring boundary is current. |
|
||||
|
||||
### Current contributor pages
|
||||
|
||||
- [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations.
|
||||
- [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft.
|
||||
|
||||
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
|
||||
|
||||
## Migration backlog — not current developer guidance
|
||||
|
||||
These are source candidates or stale records, not verified current instructions:
|
||||
|
||||
- [`archived TUI PRD`](../archive/tui/PRD-TUI_Improvements.md) — contradicted/stale; it references a missing `packages/cli`, while current TUI code is under `packages/mosaic`.
|
||||
- [`archived TUI task ledger`](../archive/tui/TASKS-TUI_Improvements.md) — historical task ledger; its status and worktree claims require revalidation.
|
||||
- `_old_structure/guides/dev-guide.md` — quarantined historical source; verify paths and commands before promotion. See the [documentation catalog](../reports/documentation/2026-08-10-docs-catalog-audit.md) for its disposition.
|
||||
|
||||
Do not make a legacy or archived page current by linking it from a chapter as if it were already promoted.
|
||||
|
||||
## Authoring boundary
|
||||
|
||||
New developer documentation belongs under one of the chapter directories above. Architecture decisions and RFCs must identify their status and authority; executable behavior must be checked against current code and tests.
|
||||
|
||||
## Related
|
||||
|
||||
- [[README|Documentation contract]]
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
- [[API/README|API index]]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Architecture
|
||||
|
||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
||||
|
||||
This chapter is the canonical home for Mosaic Stack's system model, component boundaries, data and control flow, security model, architecture decisions, and RFCs. It explains why the system has its shape; it does not replace [`PRD.md`](../../PRD.md), [`TASKS.md`](../../TASKS.md), or the API contract.
|
||||
|
||||
## Promoted pages
|
||||
|
||||
- [`lease-broker-protocol.md`](lease-broker-protocol.md) — authenticated Unix-socket protocol, identity binding, framing, persistence, and lease transitions.
|
||||
- [`lease-broker-security.md`](lease-broker-security.md) — identity, ancestry, filesystem, whole-class, observer, and named residual security boundaries.
|
||||
- [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary.
|
||||
- [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window.
|
||||
- [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft.
|
||||
- [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held.
|
||||
|
||||
These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior.
|
||||
|
||||
## Planned pages
|
||||
|
||||
| Path | Purpose | Status |
|
||||
| ----------------------------------- | --------------------------------------------------------------------- | ------------------------- |
|
||||
| `system-overview.md` | Platform boundary and major request, event, and agent-runtime flows. | Planned. |
|
||||
| `component-map.md` | Apps, packages, plugins, and dependency ownership. | Planned. |
|
||||
| `data-flow.md` | Data, event, and control-plane movement. | Planned. |
|
||||
| `security-model.md` | Trust boundaries, authority, authentication, and authorization model. | Planned. |
|
||||
| [`decisions/`](decisions/README.md) | Approved architecture decision records. | Partially migrated. |
|
||||
| [`rfcs/`](rfcs/README.md) | Proposals and protocol RFCs. | Draft egress RFC indexed. |
|
||||
|
||||
### Draft RFCs
|
||||
|
||||
- [`rfcs/optional-ai-egress-gateways.md`](rfcs/optional-ai-egress-gateways.md) — proposed model-egress boundary; not approved or integrated.
|
||||
|
||||
Promoted pages must be linked here, from [`DEVELOPER-GUIDE/README.md`](../README.md), and from [`SITEMAP.md`](../../SITEMAP.md). Do not create duplicate architecture pages in `docs/mosaic-stack/` or the docs root.
|
||||
|
||||
## Migration backlog — not current architecture
|
||||
|
||||
- [`docs/README.md`](../../README.md) — current documentation contract and placement rules.
|
||||
- [`Documentation information architecture design`](../../plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision, not product architecture.
|
||||
- [`Documentation catalog audit`](../../reports/documentation/2026-08-10-docs-catalog-audit.md) — evidence and migration recommendations, not normative architecture.
|
||||
|
||||
## Source-of-truth boundary
|
||||
|
||||
Architecture pages explain approved design and current system boundaries. Requirements remain in [`PRD.md`](../../PRD.md); active work remains in [`TASKS.md`](../../TASKS.md); executable behavior remains authoritative in source and tests. Draft proposals belong in `rfcs/` or [`docs/plans/`](../../plans/), with status clearly labeled.
|
||||
|
||||
## Related
|
||||
|
||||
- [[README|Documentation contract]]
|
||||
- [[DEVELOPER-GUIDE/README|Developer guide]]
|
||||
- [[PRD|Product requirements]]
|
||||
- [[API/README|API index]]
|
||||
@@ -0,0 +1,285 @@
|
||||
# Channel protocol architecture
|
||||
|
||||
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
||||
>
|
||||
> **Audience:** Developers maintaining `@mosaicstack/types`, channel plugins, the gateway chat/plugin boundaries, or future official adapters.
|
||||
>
|
||||
> **Last verified:** 2026-08-10 against the source and focused tests listed in [Evidence](#evidence).
|
||||
>
|
||||
> **Authority:** Executable source and tests are authoritative for current behavior. This page explains the boundary; it is not a runtime registry, an API contract, a requirements document, or proof that every channel uses the shared DTOs.
|
||||
|
||||
## Reading this page
|
||||
|
||||
This page intentionally separates three states:
|
||||
|
||||
- **Current** — implemented in the repository and supported by the cited tests.
|
||||
- **Compatibility** — an existing wire path that preserves current behavior but does not yet mean that the shared channel ports are wired through the gateway.
|
||||
- **Draft** — a design direction or follow-up work item. Draft sections have no implementation authority and must not be used as instructions for operating Telegram, Matrix, identity linking, or cross-surface fanout.
|
||||
|
||||
The migration from `docs/_old_structure/architecture/channel-protocol.md` is a documentation correction. It does not add adapters, change gateway behavior, change authentication, or create database objects.
|
||||
|
||||
## Authority and evidence boundaries
|
||||
|
||||
| Boundary | Current authority | What this page may claim |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Shared channel types and ports | [`channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts), [`channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts), and their exports | The TypeScript shapes and method signatures that are currently published from `@mosaicstack/types`. |
|
||||
| Discord native behavior | [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) and [`index.test.ts`](../../../plugins/discord/src/index.test.ts) | The Discord allowlist, pairing, role, thread, ingress, egress, retry, and health behavior covered by source and tests. |
|
||||
| Discord gateway compatibility | [`chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts), [`chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts), and the focused gateway tests | The signed Socket.IO service path, gateway validation, raw chat events, and current session dispatch behavior. |
|
||||
| Plugin hosting | [`plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) | The lifecycle registry that exists today. It is not evidence of a shared `OfficialChannelAdapter` registry. |
|
||||
| Telegram | [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) and [`package.json`](../../../plugins/telegram/package.json) | The raw legacy behavior that exists. It is not evidence of shared-contract parity or a working authenticated gateway integration. |
|
||||
| Matrix, identity linking, and multiplexing | No matching current implementation and test boundary was found for the old page's designs | These topics remain explicitly draft/unimplemented here. |
|
||||
|
||||
The current source boundaries also distinguish two identities:
|
||||
|
||||
1. A channel route carries a configuration-owned logical agent and response destination.
|
||||
2. The gateway chooses provider, model, and runtime session internally. Durable-session enrollment is separate and is not proven by the external channel route alone.
|
||||
|
||||
A route is therefore not a claim that a channel adapter owns or exposes a harness, provider, model, process, or native runtime-session identity.
|
||||
|
||||
## Current shared contract
|
||||
|
||||
The channel types are exported through `packages/types/src/channel/index.ts` and `packages/types/src/index.ts`. They define a transport-neutral vocabulary, but TypeScript interfaces alone do not prove that every producer or consumer uses that vocabulary.
|
||||
|
||||
### DTOs
|
||||
|
||||
The current DTO surface is:
|
||||
|
||||
| Type | Current shape and boundary |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChannelMetadataValue` | JSON-safe strings, numbers, booleans, `null`, arrays, and nested objects. |
|
||||
| `ChannelAttachmentDto` | `id`, `name`, `mimeType`, `url`, and optional `sizeBytes`. |
|
||||
| `ChannelMessageDto` | `id`, `channelName`, `channelId`, `senderId`, `senderKind`, `content`, `contentKind`, `timestamp`, and `metadata`; `threadId`, `replyToId`, and `attachments` are optional. |
|
||||
| `ChannelAuthorizedPrincipalDto` | Native `channelUserId`, a `viewer`/`operator`/`admin` role, and an optional `mosaicUserId` for privileged gateway policy. |
|
||||
| `ChannelBindingDto` | Configuration-owned `bindingId`, `channelName`, `workspaceId`, `channelId`, `logicalAgentId`, and paired `principals`. Credentials are intentionally absent. |
|
||||
| `ChannelResponseTargetDto` | `channelId` and an optional `threadId`. |
|
||||
| `ChannelConversationRouteDto` | `bindingId`, `logicalAgentId`, `conversationId`, `channelName`, `authorizationChannelId`, and `responseTarget`. |
|
||||
| `ChannelIngressDto` | `correlationId`, `nativeMessageId`, an operation, an authorized principal, a normalized message, and a stable route. |
|
||||
| `ChannelEgressDto` | `correlationId`, a normalized message, and the stable route. |
|
||||
| `ChannelAdapterHealthDto` | `status` of `connected`, `degraded`, or `disconnected`, with optional `detail`. |
|
||||
|
||||
The available operations are `message.send`, `approval.create`, and `session.stop`. The available sender kinds are `user`, `agent`, and `system`; content kinds are `text`, `markdown`, `code`, `image`, and `file`.
|
||||
|
||||
### Lifecycle and ports
|
||||
|
||||
The shared adapter file currently defines these seams:
|
||||
|
||||
```typescript
|
||||
interface OfficialChannelAdapter {
|
||||
readonly name: string;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
health(): Promise<ChannelAdapterHealthDto>;
|
||||
}
|
||||
|
||||
interface ChannelIngressPort {
|
||||
receive(ingress: ChannelIngressDto): Promise<void>;
|
||||
}
|
||||
|
||||
interface ChannelEgressPort {
|
||||
send(egress: ChannelEgressDto): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`ChannelDeliveryError` currently has only these codes: `invalid_route`, `destination_unavailable`, and `delivery_failed`. The type surface does not define a revoked-auth error code or an executable protocol version `1.0.0`.
|
||||
|
||||
### Stable route rule
|
||||
|
||||
`ChannelConversationRouteDto` deliberately omits provider, harness, model, process, and native runtime-session fields. The Discord implementation derives its current conversation address as:
|
||||
|
||||
```text
|
||||
<logical-agent-id>:discord:<response-channel-id>
|
||||
```
|
||||
|
||||
and derives its binding address from the configured guild, parent channel, and logical-agent instance. The gateway validates the expected Discord conversation address before dispatch. This is a route-integrity rule, not a claim that the shared DTO is already the gateway's universal session API.
|
||||
|
||||
### What is and is not wired today
|
||||
|
||||
The Discord class implements both `OfficialChannelAdapter` and `ChannelEgressPort`, and accepts an optional `ChannelIngressPort` dependency. The direct ingress seam is exercised by the Discord tests. However, the gateway host currently registers `IChannelPlugin` objects with only `name`, `start`, `stop`, and optional project provisioning. Its `PLUGIN_REGISTRY` is an array of those lifecycle wrappers; it does not expose `health()`, `ChannelRegistry.healthAll()`, or shared port wiring.
|
||||
|
||||
The gateway's current output path is also still Socket.IO event streaming (`agent:start`, `agent:text`, and `agent:end`). No gateway service in the cited implementation produces a `ChannelEgressDto` for a registered adapter. The shared ports are therefore current contracts and a tested Discord seam, not a completed gateway-wide adapter architecture.
|
||||
|
||||
## Current Discord compatibility path
|
||||
|
||||
Discord is the current reference implementation for the shared contract and the compatibility path. Its behavior is split between native Discord translation in the plugin and gateway-side validation/dispatch.
|
||||
|
||||
### Native ingress and authorization
|
||||
|
||||
For an inbound Discord message, the plugin currently:
|
||||
|
||||
1. Ignores bot-authored messages and messages without a guild.
|
||||
2. Uses the configured parent text channel as the authorization channel only when the message is in a thread. A normal channel's category parent is not substituted for the channel itself.
|
||||
3. Applies default-deny guild, channel, and user allowlists.
|
||||
4. Resolves a configuration-owned binding and paired user role before creating a thread or dispatching to the gateway. `viewer` cannot send turns; approval and stop are admin operations.
|
||||
5. Applies per-user/channel message and mention-thread rate limits before Discord thread creation or gateway dispatch.
|
||||
6. Builds a stable route from the configured logical-agent instance and the response channel/thread.
|
||||
7. Normalizes the authorized turn to `ChannelIngressDto` when a direct `ingressPort` dependency is supplied.
|
||||
|
||||
The normalized `ChannelMessageDto` currently includes:
|
||||
|
||||
- `channelName: "discord"`;
|
||||
- the response channel as `channelId`;
|
||||
- the Discord author as `senderId` and `senderKind: "user"`;
|
||||
- `markdown` for non-empty text, or `image`/`file` for attachment-only input;
|
||||
- attachments mapped to `ChannelAttachmentDto`; and
|
||||
- `metadata` containing `channelMessageId` and `guildId`.
|
||||
|
||||
The implementation does **not** currently populate `channelType`, mentions, embeds, or `replyToId` in that normalized metadata. The old page's broader Discord metadata table must not be treated as current behavior.
|
||||
|
||||
### Direct shared ingress versus compatibility envelope
|
||||
|
||||
When a direct port is present, the plugin calls `ChannelIngressPort.receive()` with the complete normalized ingress DTO. In the current gateway-hosted path, the plugin instead signs a compatibility envelope and emits one of these Socket.IO events:
|
||||
|
||||
| Shared operation | Compatibility event | Current envelope boundary |
|
||||
| ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `message.send` | `message` | Correlation ID, native Discord message ID, guild/channel/user IDs, conversation ID, content, optional thread ID, and attachments. |
|
||||
| `approval.create` | `discord:approve` | The same signed Discord identity and route fields, carrying the approval command. |
|
||||
| `session.stop` | `discord:stop` | The same signed Discord identity and route fields, carrying the stop command. |
|
||||
|
||||
The signature is HMAC-SHA-256 over the ordered envelope payload using the injected Discord service token. The token is used for service authentication and is not part of the protocol payload.
|
||||
|
||||
### Gateway validation and dispatch
|
||||
|
||||
The gateway exposes the `/chat` Socket.IO namespace. A Discord connection authenticates with `discordServiceToken`; ordinary clients use a BetterAuth session. For Discord service messages, the gateway:
|
||||
|
||||
1. Verifies the signed envelope with `DISCORD_SERVICE_TOKEN`.
|
||||
2. Re-applies the configured guild, channel, and user allowlists.
|
||||
3. Resolves the configured binding and operation role.
|
||||
4. Checks that the conversation ID matches the bound logical-agent instance and channel/thread.
|
||||
5. Rejects a repeated native Discord message ID through the bounded replay protector.
|
||||
6. Reconstructs a gateway `ChatSocketMessageDto` containing the conversation ID, content, and validated attachments.
|
||||
7. Uses the configured Discord service principal/tenant for ordinary chat dispatch and the paired `mosaicUserId` for privileged approval/stop policy where required.
|
||||
8. Selects the trusted `agentConfigId` from the binding and verifies that the provisioned agent name matches the binding's logical-agent instance.
|
||||
|
||||
This path is intentionally described as compatibility: the gateway receives a signed Discord envelope and reconstructs chat input; it does not currently receive a complete `ChannelIngressDto` from the host registry.
|
||||
|
||||
### Persistence and durability boundary
|
||||
|
||||
The Discord `conversationId` is an external route string such as `<logical-agent-id>:discord:<channel-or-thread-id>`. Persisted conversations and messages use UUID conversation IDs. No current route-mapping layer was found that resolves the external route to a generated UUID before ordinary Discord writes. The gateway catches persistence/binding failures and may continue dispatch, so live output does not prove durable history or restart/resume continuity.
|
||||
|
||||
The focused cross-surface integration test explicitly pre-enrolls a durable session before exercising control flow. It does not prove that a fresh ordinary Discord message creates durable conversation/message rows. Current architecture claims are therefore limited to authenticated routing and live delivery. Durable Discord continuity requires a route-to-UUID mapping, observable persistence failures, ordinary-ingress enrollment where required, and a fresh-database restart test.
|
||||
|
||||
### Thread and conversation behavior
|
||||
|
||||
| Inbound case | Current route and side effect |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Authorized untagged message in a parent channel | Uses the parent channel as the response target; no thread is created. |
|
||||
| Authorized bot mention in a parent channel | Creates a public thread, or reuses the thread already attached to that message, and routes the response to that thread. |
|
||||
| Authorized follow-up in an existing thread | Authorizes against the configured parent channel and keeps the existing thread; it does not create a nested thread. |
|
||||
| `/approve` or `/stop <approval>` | Uses the current parent/thread route and requires an already enrolled durable session; ordinary chat does not prove enrollment. |
|
||||
| Requested thread creation fails | Does not dispatch the message, because the requested response target cannot be honored. |
|
||||
|
||||
### Discord egress and health
|
||||
|
||||
`DiscordPlugin.send()` is a typed egress implementation, even though the current gateway does not wire it as a universal `ChannelEgressPort`. It:
|
||||
|
||||
- rejects a forged or route-misaligned conversation before looking up a Discord destination;
|
||||
- rejects a missing destination with `destination_unavailable`;
|
||||
- chunks text at a Discord-safe 1,900-character boundary;
|
||||
- retries transient rate-limit, server, and network failures up to three attempts;
|
||||
- derives one stable nonce per correlation/chunk and sends with Discord's enforced nonce option; and
|
||||
- does not retry permanent delivery failures.
|
||||
|
||||
The plugin reports `connected`, `degraded`, or `disconnected` from Discord client readiness and gateway socket connectivity. The health method is tested without exposing provider or runtime state. Outbound delivery uses Discord's native send operation; code-content wrapping from the former page is not implemented.
|
||||
|
||||
Agent output reaches the plugin through the current `agent:start`/`agent:text`/`agent:end` Socket.IO events. The plugin buffers the text by conversation ID and calls its typed Discord egress method when the stream ends.
|
||||
|
||||
## Draft: shared adapter registry and gateway wiring
|
||||
|
||||
**Status: Draft / unimplemented.**
|
||||
|
||||
The gateway does have a startup `IChannelPlugin[]` registry, but that registry is a lifecycle host for the current Discord and Telegram wrappers. It does not register `OfficialChannelAdapter` instances, inject `ChannelIngressPort` and `ChannelEgressPort` through a common gateway service, expose adapter health, or implement a dynamic `ChannelRegistry` with `getAdapter`, `listAdapters`, and `healthAll` semantics.
|
||||
|
||||
The former page's claim that adapters are already registered uniformly, or that new adapters can be added without channel-specific gateway branches, is not current. The gateway still has Discord-specific authentication, envelope, approval, stop, replay, and binding branches.
|
||||
|
||||
A future implementation may define a registry and host lifecycle, but that work must first specify:
|
||||
|
||||
- ownership and injection of ingress and egress ports;
|
||||
- health and failure semantics;
|
||||
- binding and credential loading boundaries;
|
||||
- compatibility behavior for existing Socket.IO clients; and
|
||||
- tests proving that an adapter cannot bypass gateway authorization or route validation.
|
||||
|
||||
Until then, this section is design context only.
|
||||
|
||||
## Draft: Telegram shared-contract parity
|
||||
|
||||
**Status: Raw legacy adapter exists; shared protocol parity and authenticated gateway participation are unimplemented/unproven.**
|
||||
|
||||
The current Telegram plugin is not an `OfficialChannelAdapter` implementation. Its source currently:
|
||||
|
||||
- launches a Telegraf bot and a Socket.IO client;
|
||||
- accepts only messages with a text field and ignores attachment-only messages;
|
||||
- maps each Telegram chat ID to `telegram-<chatId>`;
|
||||
- emits a raw `{ conversationId, content, role: "user" }` object rather than `ChannelIngressDto`;
|
||||
- has no shared DTO import, channel binding, principal/role policy, native message ID, attachment mapping, route validation, or health method; and
|
||||
- sends plain `sendMessage` responses in chunks, without the former page's claimed MarkdownV2, photo, or document handling.
|
||||
|
||||
The Telegram Socket.IO client does not provide the Discord service token or a BetterAuth session in its connection options. The source therefore does not establish participation in the gateway's current authenticated connection path. The package's test script uses `--passWithNoTests`, and no package test file is present in this checkout.
|
||||
|
||||
Future Telegram parity is draft work. It would need an explicit identity/authentication boundary, shared ingress normalization, binding and operation policy, route-safe egress, health reporting, and focused tests before this page could describe Telegram as an official shared-contract adapter.
|
||||
|
||||
## Draft: Matrix integration
|
||||
|
||||
**Status: Draft / unimplemented in the channel protocol.**
|
||||
|
||||
No current gateway adapter, shared-port wiring, channel binding, identity resolver, room/conversation persistence boundary, or focused channel tests were found for the Matrix design described by the former page. The old Conduit choice, appservice registration, room and Space mappings, ghost users, encryption defaults, retention jobs, and agent-room behavior are therefore proposals, not current system behavior.
|
||||
|
||||
Those details must not be copied into implementation instructions or treated as deployment requirements. A future Matrix effort must independently decide and implement its homeserver/appservice boundary, authentication, route mapping, persistence, authorization, delivery, and tests.
|
||||
|
||||
## Draft: channel identity linking
|
||||
|
||||
**Status: Draft / unimplemented.**
|
||||
|
||||
The shared contract carries an already-authorized `ChannelAuthorizedPrincipalDto`; it does not implement a generic channel-identity database or linking flow. Discord currently uses configuration-owned allowlists and pairings. A pairing may include a `mosaicUserId` for privileged operations, while ordinary Discord chat dispatch uses the configured service principal and tenant in the gateway.
|
||||
|
||||
No current evidence establishes the former page's proposed `channel_identities` table, OAuth/deep-link flow, anonymous-principal behavior, persistent Matrix session, or revocation endpoint. Those are not implied by the optional `mosaicUserId` field and must remain planned work until schema, auth, gateway, and adapter implementations exist together.
|
||||
|
||||
## Draft: multi-surface conversation multiplexing
|
||||
|
||||
**Status: Partial raw chat-session support exists; the proposed channel-protocol fanout architecture is unimplemented.**
|
||||
|
||||
The gateway currently tracks client/conversation sessions and emits raw typed Socket.IO chat events. Focused gateway tests verify isolation of concurrent conversation streams sharing one socket. The chat event contract is `ChatMessagePayload` plus `agent:*` events, not `ChannelMessageDto` fanout.
|
||||
|
||||
There is no evidence in the cited current path for the former page's complete `ConversationService` plus Valkey pub/sub topology, canonical cross-surface `ChannelMessageDto` persistence, or Matrix fanout. Concurrent stream isolation must not be presented as multi-surface channel multiplexing.
|
||||
|
||||
A future multiplexing design must define canonical message ownership, subscription and fanout boundaries, replay/ordering behavior, conflict semantics, and per-surface authorization before it can become architecture guidance.
|
||||
|
||||
## Current limitations and version boundary
|
||||
|
||||
The following are intentionally not claimed as current protocol policy:
|
||||
|
||||
- A semantic protocol version of `1.0.0`. `packages/types/package.json` currently reports package version `0.0.2`, while `packages/types/src/index.ts` exports `VERSION = "0.0.0"`; no migration machinery is present in the cited channel code.
|
||||
- A generic revoked-auth `ChannelDeliveryError` code. The current union contains only `invalid_route`, `destination_unavailable`, and `delivery_failed`.
|
||||
- Structured log records with a universal `{ channel, event, ... }` schema. Current plugin logs are string-prefixed.
|
||||
- Universal adapter health monitoring by the gateway host. Discord exposes health; the `IChannelPlugin` host does not.
|
||||
- Complete `ChannelEgressDto` delivery from the gateway to every channel. Discord's current gateway egress remains Socket.IO stream events followed by plugin-side delivery.
|
||||
|
||||
These limitations are evidence boundaries, not requests to change implementation in this documentation migration.
|
||||
|
||||
## Evidence
|
||||
|
||||
Current-contract evidence:
|
||||
|
||||
- [`packages/types/src/channel/channel.dto.ts`](../../../packages/types/src/channel/channel.dto.ts) — DTOs, enums, route fields, and metadata shape.
|
||||
- [`packages/types/src/channel/channel-adapter.ts`](../../../packages/types/src/channel/channel-adapter.ts) — adapter lifecycle, ingress/egress ports, and delivery errors.
|
||||
- [`packages/types/src/channel/index.ts`](../../../packages/types/src/channel/index.ts) and [`packages/types/src/index.ts`](../../../packages/types/src/index.ts) — export surface and current package `VERSION`.
|
||||
|
||||
Discord evidence:
|
||||
|
||||
- [`plugins/discord/src/index.ts`](../../../plugins/discord/src/index.ts) — native translation, authorization, thread routing, signed compatibility envelope, typed ingress/egress seam, retry, and health.
|
||||
- [`plugins/discord/src/index.test.ts`](../../../plugins/discord/src/index.test.ts) — direct ingress, routing/thread behavior, authorization ordering, attachments, route-safe egress, retries, chunking, and health.
|
||||
- [`apps/gateway/src/chat/chat.gateway.ts`](../../../apps/gateway/src/chat/chat.gateway.ts) — `/chat` namespace, service/session authentication, signed-envelope reconstruction, trusted binding selection, and raw stream egress.
|
||||
- [`apps/gateway/src/chat/chat.gateway-auth.ts`](../../../apps/gateway/src/chat/chat.gateway-auth.ts) — Discord service-token and BetterAuth session validation.
|
||||
- [`apps/gateway/src/plugin/discord-ingress.security.spec.ts`](../../../apps/gateway/src/plugin/discord-ingress.security.spec.ts) — signature, allowlist, replay, binding, attachment, approval, stop, and logical-agent checks.
|
||||
- [`apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts`](../../../apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts) — focused control-flow test with explicit durable-session pre-enrollment; not ordinary persistence evidence.
|
||||
|
||||
Hosting and compatibility evidence:
|
||||
|
||||
- [`apps/gateway/src/plugin/plugin.interface.ts`](../../../apps/gateway/src/plugin/plugin.interface.ts), [`plugin.module.ts`](../../../apps/gateway/src/plugin/plugin.module.ts), and [`plugin.service.ts`](../../../apps/gateway/src/plugin/plugin.service.ts) — current lifecycle-only plugin host.
|
||||
- [`packages/types/src/chat/events.ts`](../../../packages/types/src/chat/events.ts) — raw Socket.IO chat event contracts.
|
||||
- [`apps/gateway/src/chat/chat.gateway-redaction.spec.ts`](../../../apps/gateway/src/chat/chat.gateway-redaction.spec.ts) — current per-client/per-conversation stream isolation evidence.
|
||||
|
||||
Telegram evidence:
|
||||
|
||||
- [`plugins/telegram/src/index.ts`](../../../plugins/telegram/src/index.ts) — current raw Telegraf and Socket.IO behavior.
|
||||
- [`plugins/telegram/package.json`](../../../plugins/telegram/package.json) — package scripts, including `--passWithNoTests`.
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
> **Status:** Current contract reference.
|
||||
> **Audience:** Developer and security reviewer.
|
||||
> **Evidence:** Mutator-gate and framework portability acceptance suites consume this page; live deployment gaps remain explicitly labeled below.
|
||||
|
||||
WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket.
|
||||
|
||||
## Observer matrix
|
||||
@@ -0,0 +1,15 @@
|
||||
# Architecture Decisions
|
||||
|
||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
||||
|
||||
## Current decisions
|
||||
|
||||
- [Mos runtime portability M1 — logical identity and fencing](mos-runtime-portability-m1.md) — implemented lease, grant, audit, policy, and fencing boundary; connector activation remains held.
|
||||
|
||||
Decision pages do not override product requirements, API contracts, or executable behavior. Each page must identify implementation evidence, operational status, and explicit non-goals.
|
||||
|
||||
## Related
|
||||
|
||||
- [[DEVELOPER-GUIDE/architecture/README|Architecture index]]
|
||||
- [[PRD|Product requirements]]
|
||||
- [[ADMIN-GUIDE/operations/mos-connector-lease-operations|Held connector lease operations]]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user