Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
694f1a4199 | ||
|
|
472dcee7ed | ||
|
|
633acd2d2a | ||
|
|
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 | ||
|
|
e00cc475a2 | ||
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
bf32f29acd | ||
|
|
1655b1579a | ||
|
|
e478a359eb | ||
|
|
76e4242cb1 | ||
|
|
a45f53071a | ||
|
|
d46a2d675a | ||
|
|
48bb19310d | ||
|
|
caebf9ef70 | ||
|
|
b2e005f2b4 | ||
|
|
dc67590a96 | ||
|
|
baf4306f51 | ||
|
|
12677a928d |
@@ -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.
|
||||
- |
|
||||
|
||||
@@ -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,197 @@ function makeScopedAgentService() {
|
||||
};
|
||||
}
|
||||
|
||||
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
|
||||
|
||||
/**
|
||||
* A structurally-complete harness conversation service that throws if any method is invoked.
|
||||
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
|
||||
*/
|
||||
const failIfUsedConversationService = {
|
||||
attach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
detach: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
send: () => {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('harness conversation service must not be reached on the legacy path');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
|
||||
const registry = new HarnessRegistry();
|
||||
for (const id of adapterIds) {
|
||||
registry.register({
|
||||
id,
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
|
||||
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
|
||||
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
|
||||
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
|
||||
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
|
||||
*/
|
||||
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(failIfUsedConversationService);
|
||||
const router = new ChatRuntimeRouter(
|
||||
new HarnessRegistry(),
|
||||
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
|
||||
embedded,
|
||||
harness,
|
||||
'legacy',
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
|
||||
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
|
||||
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
|
||||
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
|
||||
*/
|
||||
const FORBIDDEN_AGENT_OPS = [
|
||||
'getSession',
|
||||
'createSession',
|
||||
'onEvent',
|
||||
'addChannel',
|
||||
'prompt',
|
||||
'setThinking',
|
||||
'abort',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
|
||||
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
|
||||
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
|
||||
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
|
||||
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
|
||||
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
|
||||
*
|
||||
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
|
||||
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
|
||||
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
|
||||
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
|
||||
*/
|
||||
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
|
||||
return new Proxy(target, {
|
||||
get(t, prop) {
|
||||
const value = Reflect.get(t, prop);
|
||||
if (typeof value === 'function' && typeof prop === 'string') {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push(prop);
|
||||
return (value as (...a: unknown[]) => unknown).apply(t, args);
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as ChatRuntimeRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
|
||||
*
|
||||
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
|
||||
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
|
||||
* resolves whichever its constructor declares:
|
||||
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
|
||||
* router (and its embedded fake) is never reached.
|
||||
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
|
||||
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
|
||||
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
|
||||
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
|
||||
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
|
||||
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
|
||||
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
|
||||
*/
|
||||
function buildRestModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
return (
|
||||
Test.createTestingModule({
|
||||
controllers: [ChatController],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
})
|
||||
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
|
||||
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
|
||||
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile()
|
||||
);
|
||||
}
|
||||
|
||||
function buildGatewayModule(
|
||||
directAgentService: ScopedAgentService,
|
||||
embeddedAgentService: ScopedAgentService,
|
||||
routerCalls: string[],
|
||||
): Promise<TestingModule> {
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
return Test.createTestingModule({
|
||||
providers: [
|
||||
ChatGateway,
|
||||
{ provide: AgentService, useValue: directAgentService },
|
||||
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
|
||||
{ provide: BRAIN, useValue: brain },
|
||||
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
|
||||
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
|
||||
{
|
||||
provide: RoutingEngineService,
|
||||
useValue: {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: () =>
|
||||
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||
@@ -152,50 +366,66 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new ChatController(agentService as never);
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
|
||||
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
|
||||
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
|
||||
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
|
||||
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
|
||||
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
|
||||
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
|
||||
try {
|
||||
const controller = moduleRef.get(ChatController, { strict: false });
|
||||
|
||||
await expect(
|
||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
|
||||
await expect(
|
||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
|
||||
|
||||
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
|
||||
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
|
||||
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
|
||||
expect
|
||||
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
|
||||
.toContain('completeLegacyRestTurn');
|
||||
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
|
||||
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
|
||||
// on invocation evidence (not source text). A dead named method added alongside the shim does not
|
||||
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
|
||||
// restoring the direct injection keeps it failing).
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
|
||||
// router (fails today; the router path is never taken).
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
|
||||
// Zero foreign mutation on either path (holds today and at GREEN).
|
||||
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||
|
||||
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
|
||||
// controller no longer declares the direct embedded AgentService dependency. A negative source
|
||||
// check cannot be satisfied by dead text — it only fails when the injection is present.
|
||||
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
||||
function makeGateway(agentService = makeScopedAgentService()) {
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
||||
const commandExecutor = { execute: vi.fn() };
|
||||
const routingEngine = {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
commandRegistry as never,
|
||||
commandExecutor as never,
|
||||
routingEngine as never,
|
||||
);
|
||||
return { gateway, agentService };
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
|
||||
function makeSocket() {
|
||||
return {
|
||||
id: 'socket-b',
|
||||
@@ -206,57 +436,368 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () =>
|
||||
};
|
||||
}
|
||||
|
||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
// TESS test B — WebSocket send/attach.
|
||||
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
});
|
||||
await Promise.resolve(
|
||||
gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
|
||||
.toContain('prepareLegacySocketTurn');
|
||||
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
|
||||
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
|
||||
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
|
||||
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
|
||||
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
|
||||
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
|
||||
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test C — WebSocket set:thinking.
|
||||
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleSetThinking(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
level: 'high',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
|
||||
.toContain('setLegacyThinking');
|
||||
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test D — WebSocket abort.
|
||||
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
|
||||
const directAgentService = makeScopedAgentService();
|
||||
const embeddedAgentService = makeScopedAgentService();
|
||||
const routerCalls: string[] = [];
|
||||
const moduleRef = await buildGatewayModule(
|
||||
directAgentService,
|
||||
embeddedAgentService,
|
||||
routerCalls,
|
||||
);
|
||||
try {
|
||||
const gateway = moduleRef.get(ChatGateway, { strict: false });
|
||||
const socket = makeSocket();
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
|
||||
).catch(() => undefined);
|
||||
|
||||
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
|
||||
expect
|
||||
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
|
||||
.toContain('abortLegacyTurn');
|
||||
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
|
||||
for (const op of FORBIDDEN_AGENT_OPS) {
|
||||
expect
|
||||
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
|
||||
.not.toContain(op);
|
||||
}
|
||||
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
|
||||
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect
|
||||
.soft(socket.emit)
|
||||
.toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
|
||||
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
|
||||
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
|
||||
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
|
||||
// harness conversation service.
|
||||
const agentService = makeScopedAgentService();
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harnessConversation = {
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
send: vi.fn(),
|
||||
subscribeFrom: vi.fn(),
|
||||
};
|
||||
const harness = new HarnessChatRuntime(harnessConversation as never);
|
||||
const router = new ChatRuntimeRouter(
|
||||
registryWith(['pi']),
|
||||
boundConversationService,
|
||||
embedded,
|
||||
harness,
|
||||
'pi-rpc',
|
||||
);
|
||||
router.onModuleInit();
|
||||
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
router as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{ getManifest: vi.fn().mockReturnValue([]) } as never,
|
||||
{ execute: vi.fn() } as never,
|
||||
{ resolve: vi.fn() } as never,
|
||||
);
|
||||
const socket = {
|
||||
id: 'socket-b',
|
||||
connected: true,
|
||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
await Promise.resolve(
|
||||
gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'route me',
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ code: 'runtime_unsupported' }),
|
||||
);
|
||||
expect(agentService.getSession).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
expect(harnessConversation.attach).not.toHaveBeenCalled();
|
||||
expect(harnessConversation.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
|
||||
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
|
||||
// fake (every touched method exists, so a RED can only come from behavior, never a
|
||||
// `getSession is not a function` TypeError). Ownership context is minted through the
|
||||
// real `ownConversation` factory — the only sanctioned way to reach a port op.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
|
||||
const CONVERSATION_UNAVAILABLE_RESULT = {
|
||||
ok: false,
|
||||
code: 'conversation_unavailable',
|
||||
retryable: false,
|
||||
} as const;
|
||||
|
||||
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
|
||||
function makeStream(): LegacyRuntimeStream {
|
||||
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
|
||||
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
|
||||
* the turn short-circuited before any dispatch.
|
||||
*/
|
||||
function makeCollapsingAgentService(err: Error) {
|
||||
return {
|
||||
getSession: vi.fn(() => undefined),
|
||||
createSession: vi.fn().mockRejectedValue(err),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
|
||||
function makeLeaseAgentService() {
|
||||
const session = makeAgentSession(USER_A);
|
||||
const unsubscribe = vi.fn();
|
||||
const svc = {
|
||||
getSession: vi.fn(() => session),
|
||||
createSession: vi.fn(),
|
||||
onEvent: vi.fn(() => unsubscribe),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
recordTokenUsage: vi.fn(),
|
||||
};
|
||||
return { svc, unsubscribe, session };
|
||||
}
|
||||
|
||||
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
|
||||
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
|
||||
|
||||
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
expect(svc.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
|
||||
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
|
||||
|
||||
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
expect(svc.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
|
||||
const foreign = new EmbeddedChatRuntime(
|
||||
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
|
||||
);
|
||||
const missing = new EmbeddedChatRuntime(
|
||||
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
|
||||
);
|
||||
|
||||
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
|
||||
|
||||
expect(foreignResult).toEqual(missingResult);
|
||||
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
|
||||
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
|
||||
|
||||
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
|
||||
const { svc } = makeLeaseAgentService();
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
|
||||
expect(prepared.ok).toBe(true);
|
||||
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
|
||||
const lease = prepared.value;
|
||||
|
||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||
const first = await lease.dispatch();
|
||||
expect(first).toEqual({ ok: true, value: undefined });
|
||||
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
const second = await lease.dispatch();
|
||||
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
|
||||
// Zero additional effect — the second dispatch must not prompt again.
|
||||
expect(svc.prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
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);
|
||||
|
||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
||||
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;
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
await lease.dispose();
|
||||
await lease.dispose();
|
||||
|
||||
// Listener + channel torn down exactly once across two dispose calls.
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
|
||||
// Disposal never terminates the underlying session or process.
|
||||
expect(session.piSession.abort).not.toHaveBeenCalled();
|
||||
expect(session.piSession.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
|
||||
const { svc, unsubscribe } = makeLeaseAgentService();
|
||||
svc.addChannel = vi.fn(() => {
|
||||
throw new Error('channel attach failed');
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
const runtime = new EmbeddedChatRuntime(svc as never);
|
||||
|
||||
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
|
||||
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
|
||||
expect(prepared.ok).toBe(false);
|
||||
// Exactly what was acquired (the event listener) is rolled back.
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ 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';
|
||||
@@ -60,6 +61,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
PreferencesModule,
|
||||
CommandsModule,
|
||||
GCModule,
|
||||
HarnessModule,
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ValidationPipe, type ArgumentMetadata } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { validateSync, type ValidationError } from 'class-validator';
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
|
||||
import { AgentService } from '../../agent/agent.service.js';
|
||||
import { AuthGuard } from '../../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from '../../harness/harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from '../../harness/harness.tokens.js';
|
||||
import type { AuthenticatedUserLike } from '../../auth/session-scope.js';
|
||||
import { SendMessageDto } from '../../conversations/conversations.dto.js';
|
||||
import { ChatRequestDto } from '../chat.dto.js';
|
||||
import { ChatController } from '../chat.controller.js';
|
||||
import { ChatGateway } from '../chat.gateway.js';
|
||||
import { ChatRuntimeRouter } from '../chat-runtime-router.js';
|
||||
import { EmbeddedChatRuntime } from '../embedded-chat.runtime.js';
|
||||
import { HarnessChatRuntime } from '../harness-chat.runtime.js';
|
||||
import type { ChatRuntime } from '../chat-runtime.js';
|
||||
import { ChatRequestDto, HarnessTurnSendDto } from '../chat.dto.js';
|
||||
import { validateSocketSession } from '../chat.gateway-auth.js';
|
||||
|
||||
describe('Chat controller source hardening', () => {
|
||||
@@ -17,6 +31,328 @@ describe('Chat controller source hardening', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chat runtime routing hardening (Task Five)', () => {
|
||||
it('routes /api/chat through the exclusive ChatRuntimeRouter, never the embedded AgentService', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
// pi-rpc /api/chat must resolve execution through the one runtime router and never
|
||||
// reach into embedded agent execution. Legacy embedded behaviour lives behind
|
||||
// EmbeddedChatRuntime, reachable only via the router in legacy mode.
|
||||
expect(source).toContain('ChatRuntimeRouter');
|
||||
expect(source).not.toContain('@Inject(AgentService)');
|
||||
expect(source).not.toContain("from '../agent/agent.service.js'");
|
||||
});
|
||||
|
||||
it('gateway no longer injects the embedded AgentService or RoutingEngineService', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('ChatRuntimeRouter');
|
||||
expect(source).not.toContain('@Inject(AgentService)');
|
||||
expect(source).not.toContain('@Inject(RoutingEngineService)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Harness turn:send DTO validation (Task Five, group 2 — frozen wire contract, production pipe)', () => {
|
||||
// Correction #2 (Scrappy fe3e02): drive PLAIN wire payloads through the EXACT production
|
||||
// validation the gateway applies to inbound bodies — the global ValidationPipe in
|
||||
// apps/gateway/src/main.ts: { whitelist, forbidNonWhitelisted, transform }. This exercises
|
||||
// the real plainToInstance transform, nested @Type/@ValidateNested recursion, and whitelist
|
||||
// stripping — the path a turn:send actually travels — rather than a hand-built class instance
|
||||
// fed to validateSync (which never runs @Type and is masked green by class-validator's
|
||||
// empty-metadata unknownValue behaviour). Anti-masking: every VALUE-rule red asserts the field
|
||||
// carries a REAL value constraint (not `whitelistValidation`/`unknownValue`), which the
|
||||
// decorator-less stub can NEVER produce; every authority-field red asserts the forbidden field
|
||||
// is rejected while a valid field is NOT — false against the stub, which over-rejects everything.
|
||||
const PRODUCTION_PIPE = () =>
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
|
||||
// Same production configuration, but hand back the raw ValidationError[] instead of throwing a
|
||||
// BadRequestException, so the test can inspect per-field constraint keys and nested children.
|
||||
const failPipe = new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
exceptionFactory: (errs: ValidationError[]) => errs as unknown as Error,
|
||||
});
|
||||
const asBody = (metatype: ArgumentMetadata['metatype']): ArgumentMetadata => ({
|
||||
type: 'body',
|
||||
metatype,
|
||||
data: '',
|
||||
});
|
||||
|
||||
const UUID_V4 = '11111111-1111-4111-8111-111111111111';
|
||||
const validSelection = () => ({ harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' });
|
||||
const validPayload = () => ({
|
||||
conversationId: UUID_V4,
|
||||
content: 'hello there',
|
||||
selection: validSelection(),
|
||||
idempotencyKey: UUID_V4,
|
||||
});
|
||||
|
||||
// Constraint keys that mean "the field was rejected for existing", NOT "its VALUE failed a real
|
||||
// rule". The decorator-less RED stub can only ever emit these (or nothing), so requiring a REAL
|
||||
// value constraint on a field is unmaskable until Step Three attaches the decorators.
|
||||
const NON_VALUE = new Set(['whitelistValidation', 'unknownValue']);
|
||||
|
||||
// Flatten the error tree to `dotted.path -> Set<constraintKey>` (parent + nested children).
|
||||
const collect = (errors: ValidationError[], prefix = ''): Map<string, Set<string>> => {
|
||||
const map = new Map<string, Set<string>>();
|
||||
const add = (path: string, keys: Iterable<string>): void => {
|
||||
const set = map.get(path) ?? new Set<string>();
|
||||
for (const key of keys) set.add(key);
|
||||
map.set(path, set);
|
||||
};
|
||||
for (const error of errors) {
|
||||
const path = prefix ? `${prefix}.${error.property}` : error.property;
|
||||
if (error.constraints) add(path, Object.keys(error.constraints));
|
||||
if (error.children?.length) for (const [p, s] of collect(error.children, path)) add(p, s);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// Run the production pipe over a plain payload; return the ValidationError[] it raised (empty
|
||||
// when the payload is accepted).
|
||||
const errorsFor = async (
|
||||
payload: unknown,
|
||||
metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
|
||||
): Promise<ValidationError[]> => {
|
||||
try {
|
||||
await failPipe.transform(payload, asBody(metatype));
|
||||
return [];
|
||||
} catch (thrown) {
|
||||
return thrown as ValidationError[];
|
||||
}
|
||||
};
|
||||
|
||||
// True when `path` is rejected by a REAL value rule (IsUUID, IsNotEmpty, MaxLength, …), i.e. a
|
||||
// constraint that is not a mere existence/whitelist rejection.
|
||||
const hasValueConstraint = async (
|
||||
payload: unknown,
|
||||
path: string,
|
||||
metatype: ArgumentMetadata['metatype'] = HarnessTurnSendDto,
|
||||
): Promise<boolean> => {
|
||||
const keys = collect(await errorsFor(payload, metatype)).get(path);
|
||||
return keys ? [...keys].some((key) => !NON_VALUE.has(key)) : false;
|
||||
};
|
||||
|
||||
// Paths rejected purely for existing outside the whitelist (authority / unknown-field defence).
|
||||
const forbiddenFields = async (payload: unknown): Promise<string[]> => {
|
||||
const out: string[] = [];
|
||||
for (const [path, keys] of collect(await errorsFor(payload))) {
|
||||
if (keys.has('whitelistValidation')) out.push(path);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// --- GREEN controls: prove the production pipe machinery genuinely accepts a well-formed,
|
||||
// already-decorated DTO AND enforces its constraints — so the group's reds below are the
|
||||
// STUB's missing decorators, not a broken harness. Both pass today. ---
|
||||
it('GREEN control: the production pipe accepts a well-formed, decorated ChatRequestDto', async () => {
|
||||
await expect(
|
||||
PRODUCTION_PIPE().transform({ content: 'hello there' }, asBody(ChatRequestDto)),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('GREEN control: the same production pipe rejects over-long ChatRequestDto content (engine truly enforces)', async () => {
|
||||
expect(
|
||||
await hasValueConstraint({ content: 'x'.repeat(10_001) }, 'content', ChatRequestDto),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// --- RED value-rule fence: each asserts the turn:send field is rejected by a REAL value rule.
|
||||
// All fail against the decorator-less stub; they go green when Step Three adds the decorators.
|
||||
// No validation implementation is permitted during RED collection. ---
|
||||
it('requires a conversation id (rejects a missing conversationId)', async () => {
|
||||
expect(
|
||||
await hasValueConstraint({ ...validPayload(), conversationId: undefined }, 'conversationId'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a UUID conversation id (rejects a non-UUID conversationId)', async () => {
|
||||
expect(
|
||||
await hasValueConstraint(
|
||||
{ ...validPayload(), conversationId: 'not-a-uuid' },
|
||||
'conversationId',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty / whitespace-only content (content is trimmed 1..10000)', async () => {
|
||||
expect(await hasValueConstraint({ ...validPayload(), content: ' ' }, 'content')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects content above 10000 characters', async () => {
|
||||
expect(
|
||||
await hasValueConstraint({ ...validPayload(), content: 'x'.repeat(10_001) }, 'content'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the nested selection triple (rejects a missing selection)', async () => {
|
||||
expect(await hasValueConstraint({ ...validPayload(), selection: undefined }, 'selection')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed selection nesting (a non-object selection)', async () => {
|
||||
expect(
|
||||
await hasValueConstraint(
|
||||
{ ...validPayload(), selection: 'pi/anthropic/claude' },
|
||||
'selection',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a selection with a blank harnessId (each id must be non-empty)', async () => {
|
||||
const payload = {
|
||||
...validPayload(),
|
||||
selection: { harnessId: '', providerId: 'anthropic', modelId: 'claude' },
|
||||
};
|
||||
expect(await hasValueConstraint(payload, 'selection.harnessId')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a selection missing the modelId', async () => {
|
||||
const payload = { ...validPayload(), selection: { harnessId: 'pi', providerId: 'anthropic' } };
|
||||
expect(await hasValueConstraint(payload, 'selection.modelId')).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a UUID-v4 idempotency key (rejects a missing key)', async () => {
|
||||
expect(
|
||||
await hasValueConstraint({ ...validPayload(), idempotencyKey: undefined }, 'idempotencyKey'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-UUID-v4 idempotency key', async () => {
|
||||
expect(
|
||||
await hasValueConstraint(
|
||||
{ ...validPayload(), idempotencyKey: 'not-a-key' },
|
||||
'idempotencyKey',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// --- RED authority/unknown-field fence: the forbidden field is rejected while a valid field is
|
||||
// NOT spuriously rejected. False against the stub (which over-rejects every field, including
|
||||
// `content`); true only once Step Three whitelists the legitimate fields. ---
|
||||
it('rejects a top-level authority field (provider) without flagging valid fields', async () => {
|
||||
const forbidden = await forbiddenFields({ ...validPayload(), provider: 'openai' });
|
||||
expect(forbidden).toContain('provider');
|
||||
expect(forbidden).not.toContain('content');
|
||||
});
|
||||
|
||||
it('rejects a top-level modelId authority field without flagging valid fields', async () => {
|
||||
const forbidden = await forbiddenFields({ ...validPayload(), modelId: 'gpt-5' });
|
||||
expect(forbidden).toContain('modelId');
|
||||
expect(forbidden).not.toContain('content');
|
||||
});
|
||||
|
||||
it('rejects an attachments field (not part of the frozen turn:send contract)', async () => {
|
||||
const forbidden = await forbiddenFields({ ...validPayload(), attachments: [{ id: 'a1' }] });
|
||||
expect(forbidden).toContain('attachments');
|
||||
expect(forbidden).not.toContain('content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chat runtime routing — behavioural /api/chat fence (Task Five, group 3)', () => {
|
||||
// The router's own runtime tokens. The controller must reach chat execution ONLY through the
|
||||
// router; the embedded AgentService below is the forbidden path proven untouched.
|
||||
const embedded: ChatRuntime = { kind: 'embedded' };
|
||||
const harness: ChatRuntime = { kind: 'harness' };
|
||||
|
||||
// Structurally-complete, non-sentinel conversation service; its methods are never invoked here.
|
||||
const boundConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
it('handles an /api/chat turn without invoking the embedded AgentService (behavioural, zero calls)', async () => {
|
||||
const calls = { getSession: 0, createSession: 0, onEvent: 0, prompt: 0 };
|
||||
// A spy standing in for the forbidden embedded runtime. `createSession` rejects so today's
|
||||
// controller bails immediately (never reaching the 120s agent-response wait) while still
|
||||
// recording that it reached into the embedded path — the RED anchor. Under Step Three the
|
||||
// router owns execution and this spy is never touched, so every counter stays 0 (GREEN).
|
||||
// Any masking mutation that re-enters embedded execution flips a counter and re-reds the test.
|
||||
const agentSpy = {
|
||||
getSession: () => {
|
||||
calls.getSession += 1;
|
||||
return undefined;
|
||||
},
|
||||
createSession: () => {
|
||||
calls.createSession += 1;
|
||||
return Promise.reject(new Error('spy: embedded AgentService must not be used'));
|
||||
},
|
||||
onEvent: () => {
|
||||
calls.onEvent += 1;
|
||||
return () => {};
|
||||
},
|
||||
prompt: () => {
|
||||
calls.prompt += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [ChatController],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: agentSpy },
|
||||
{
|
||||
// Provided so the Step-Three controller (which injects the router) still resolves here;
|
||||
// the real, empty registry seeded with a pi adapter keeps the router pi-rpc-ready.
|
||||
provide: HARNESS_REGISTRY,
|
||||
useFactory: () => {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register({
|
||||
id: 'pi',
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
return registry;
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChatRuntimeRouter,
|
||||
useFactory: (registry: HarnessRegistry) =>
|
||||
new ChatRuntimeRouter(registry, boundConversationService, embedded, harness, 'pi-rpc'),
|
||||
inject: [HARNESS_REGISTRY],
|
||||
},
|
||||
],
|
||||
})
|
||||
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard
|
||||
// injects AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so
|
||||
// the graph resolves and the test reds on BEHAVIOUR, not on a DI collection error.
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
try {
|
||||
const controller = moduleRef.get(ChatController, { strict: false });
|
||||
const user = { id: 'user-1' } as AuthenticatedUserLike;
|
||||
try {
|
||||
await controller.chat({ content: 'route me' } as ChatRequestDto, user);
|
||||
} catch {
|
||||
// Today: SERVICE_UNAVAILABLE from the rejecting spy. Under Step Three: the router path may
|
||||
// reject on the deliberately-unbound fake conversation service. Either way the
|
||||
// embedded-call counters below are the contract, not the handler's return value.
|
||||
}
|
||||
expect(calls).toEqual({ getSession: 0, createSession: 0, onEvent: 0, prompt: 0 });
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('wires the exclusive ChatRuntimeRouter into the chat module graph (defense-in-depth source check)', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.module.ts'), 'utf8');
|
||||
// The controller can only inject the router if the module actually provides it. RED today:
|
||||
// ChatModule provides only ChatGateway. GREEN once Step Three registers ChatRuntimeRouter.
|
||||
expect(source).toContain('ChatRuntimeRouter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket session authentication', () => {
|
||||
it('returns null when the handshake does not resolve to a session', async () => {
|
||||
const result = await validateSocketSession(
|
||||
@@ -47,6 +383,419 @@ describe('WebSocket session authentication', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Five, both runtime modes)', () => {
|
||||
// Scrappy C adjudication regression: a non-Discord service socket — modelled as a "Telegram"
|
||||
// client presenting a handshake token the gateway does NOT honour and carrying no Better-Auth
|
||||
// session — must be DISCONNECTED at handleConnection, never gain the `discordService` trust flag
|
||||
// or any user scope, receive no manifest and no ack, and reach NEITHER the embedded runtime NOR
|
||||
// the harness. This must hold in BOTH legacy and pi-rpc modes: introducing the exclusive
|
||||
// ChatRuntimeRouter / pi-rpc path must not open a second, non-Discord service ingress. This is a
|
||||
// GREEN control (it holds on this branch and must keep holding through Step Three); no Telegram
|
||||
// production route or plugin exists or is added — the assertion is that no such surface is
|
||||
// reachable. The runtime slot is fronted with a REAL, ready ChatRuntimeRouter over
|
||||
// EmbeddedChatRuntime + HarnessChatRuntime so that any accidental dispatch would flip a spy
|
||||
// rather than silently pass; the router is never resolved because the socket is rejected first.
|
||||
let priorMode: string | undefined;
|
||||
beforeEach(() => {
|
||||
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
});
|
||||
afterEach(() => {
|
||||
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||
});
|
||||
|
||||
// A pi-ready registry so a pi-rpc router resolves the harness cleanly at onModuleInit — modelling
|
||||
// the hostile condition where the harness is live yet the non-Discord socket is still rejected.
|
||||
const readyPiRegistry = (): HarnessRegistry => {
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register({
|
||||
id: 'pi',
|
||||
describe: () => Promise.reject(new Error('unused')),
|
||||
catalog: () => Promise.reject(new Error('unused')),
|
||||
create: () => Promise.reject(new Error('unused')),
|
||||
resume: () => Promise.reject(new Error('unused')),
|
||||
} as HarnessAdapter);
|
||||
return registry;
|
||||
};
|
||||
|
||||
// Non-sentinel conversation service so pi-rpc onModuleInit resolves the harness (does not throw
|
||||
// conversation_service_unavailable); its methods must never be invoked on the rejection path.
|
||||
const availableConversationService = {
|
||||
attach: () => Promise.reject(new Error('unused')),
|
||||
detach: () => Promise.reject(new Error('unused')),
|
||||
send: () => Promise.reject(new Error('unused')),
|
||||
|
||||
subscribeFrom: async function* () {
|
||||
throw new Error('unused');
|
||||
},
|
||||
} as unknown as HarnessConversationService;
|
||||
|
||||
const readyRouter = (
|
||||
mode: 'legacy' | 'pi-rpc',
|
||||
agentService: unknown,
|
||||
harnessConversations: unknown,
|
||||
): ChatRuntimeRouter => {
|
||||
const embedded = new EmbeddedChatRuntime(agentService as never);
|
||||
const harness = new HarnessChatRuntime(harnessConversations as never);
|
||||
const router = new ChatRuntimeRouter(
|
||||
readyPiRegistry(),
|
||||
availableConversationService,
|
||||
embedded,
|
||||
harness,
|
||||
mode,
|
||||
);
|
||||
router.onModuleInit();
|
||||
return router;
|
||||
};
|
||||
|
||||
it.each(['legacy', 'pi-rpc'] as const)(
|
||||
'disconnects a Telegram-shaped unauthenticated socket and dispatches to no runtime (%s mode)',
|
||||
async (mode) => {
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = mode;
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
// Auth stub that resolves NO session for the Telegram socket's headers — the sole gate a
|
||||
// non-Discord client must pass, and does not.
|
||||
const auth = { api: { getSession: vi.fn().mockResolvedValue(null) } };
|
||||
const gateway = new ChatGateway(
|
||||
readyRouter(mode, agentService, harnessConversations) as never,
|
||||
auth as never,
|
||||
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const client = {
|
||||
id: `telegram-raw-${mode}`,
|
||||
// A non-Discord service handshake: the gateway only honours `discordServiceToken`, so this
|
||||
// token is ignored, and there is no session cookie for validateSocketSession to resolve.
|
||||
handshake: { auth: { telegramServiceToken: 'ignored-non-discord-token' }, headers: {} },
|
||||
data: {} as Record<string, unknown>,
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleConnection(client as never);
|
||||
|
||||
// Rejected at the door: disconnected, no trust flag, no user scope, no manifest, and — the
|
||||
// Task 5 send-capability rule — no send-protocol advertisement to an unauthenticated socket.
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
expect(client.data.discordService).not.toBe(true);
|
||||
expect(client.data.user).toBeUndefined();
|
||||
expect(client.emit).not.toHaveBeenCalledWith('commands:manifest', expect.anything());
|
||||
expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything());
|
||||
|
||||
// Even if the ignored socket then attempts a message, it carries no scope, so the send path
|
||||
// never begins and no runtime is dispatched.
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
{
|
||||
conversationId: 'Nova:telegram:chat-1',
|
||||
content: 'via telegram',
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
expect(agentService.createSession).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
// A minimal authenticated-browser connection harness for the send-capability advertisement.
|
||||
const connectAuthedBrowser = async (
|
||||
mode: 'legacy' | 'pi-rpc',
|
||||
clientId: string,
|
||||
): Promise<{ emit: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> => {
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = mode;
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const auth = {
|
||||
api: {
|
||||
getSession: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ user: { id: 'user-a' }, session: { id: 'session-a' } }),
|
||||
},
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
readyRouter(mode, agentService, harnessConversations) as never,
|
||||
auth as never,
|
||||
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||
{ getManifest: vi.fn().mockReturnValue({ commands: [] }) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const client = {
|
||||
id: clientId,
|
||||
handshake: { auth: {}, headers: { cookie: 'session=abc' } },
|
||||
data: {} as Record<string, unknown>,
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
await gateway.handleConnection(client as never);
|
||||
return client as never;
|
||||
};
|
||||
|
||||
it('advertises legacy-message exactly once to an authenticated browser in legacy mode (Task 5 MAJOR-1)', async () => {
|
||||
const client = await connectAuthedBrowser('legacy', 'browser-cap-legacy');
|
||||
|
||||
// Legacy mode: the connected Gateway handles the `message` event, so it advertises
|
||||
// `legacy-message` — targeted, connection-bound, exactly once.
|
||||
expect(client.emit).toHaveBeenCalledWith('chat:send-capability', {
|
||||
protocol: 'legacy-message',
|
||||
connectionId: 'browser-cap-legacy',
|
||||
});
|
||||
const capabilityCalls = client.emit.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'chat:send-capability',
|
||||
);
|
||||
expect(capabilityCalls).toHaveLength(1);
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('advertises unavailable (never turn-send) to an authenticated browser in pi-rpc mode (Task 5 MAJOR-1)', async () => {
|
||||
const client = await connectAuthedBrowser('pi-rpc', 'browser-cap-pirpc');
|
||||
|
||||
// pi-rpc mode: the legacy `message` handler fails closed and the authenticated `turn:send`
|
||||
// handler lands in Task 15, so Task 5 advertises `unavailable` — never `turn-send`.
|
||||
expect(client.emit).toHaveBeenCalledWith('chat:send-capability', {
|
||||
protocol: 'unavailable',
|
||||
connectionId: 'browser-cap-pirpc',
|
||||
});
|
||||
const capabilityCalls = client.emit.mock.calls.filter(
|
||||
(call: unknown[]) => call[0] === 'chat:send-capability',
|
||||
);
|
||||
expect(capabilityCalls).toHaveLength(1);
|
||||
expect(capabilityCalls[0]?.[1]).not.toMatchObject({ protocol: 'turn-send' });
|
||||
});
|
||||
|
||||
it.each(['legacy', 'pi-rpc'] as const)(
|
||||
'never advertises send-capability to a Discord service socket (%s mode, Task 5 MAJOR-1)',
|
||||
async (mode) => {
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = mode;
|
||||
process.env['DISCORD_SERVICE_TOKEN'] = 'super-secret-discord-token';
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
readyRouter(mode, agentService, { append: vi.fn() }) as never,
|
||||
{ api: { getSession: vi.fn() } } as never,
|
||||
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const client = {
|
||||
id: `discord-service-${mode}`,
|
||||
handshake: { auth: { discordServiceToken: 'super-secret-discord-token' }, headers: {} },
|
||||
data: {} as Record<string, unknown>,
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleConnection(client as never);
|
||||
|
||||
// The trusted Discord service socket is not a browser; it never receives a browser
|
||||
// send-protocol advertisement.
|
||||
expect(client.data.discordService).toBe(true);
|
||||
expect(client.emit).not.toHaveBeenCalledWith('chat:send-capability', expect.anything());
|
||||
delete process.env['DISCORD_SERVICE_TOKEN'];
|
||||
},
|
||||
);
|
||||
|
||||
// Record-and-forward instrumentation at the REAL returned-lease boundary: wrap the lease's own
|
||||
// dispatch/dispose so the test observes the Gateway's invocation counts through the real
|
||||
// ChatRuntimeRouter -> EmbeddedChatRuntime path — no canned lease, synthesized method, or shim.
|
||||
const instrumentLease = (
|
||||
router: ChatRuntimeRouter,
|
||||
order: string[],
|
||||
counters: { dispatch: number; dispose: number },
|
||||
): void => {
|
||||
const realPrepare = router.prepareLegacySocketTurn.bind(router) as (
|
||||
...args: unknown[]
|
||||
) => Promise<{ ok: boolean; value?: { dispatch: () => unknown; dispose: () => unknown } }>;
|
||||
vi.spyOn(router, 'prepareLegacySocketTurn').mockImplementation((async (...args: unknown[]) => {
|
||||
const result = await realPrepare(...args);
|
||||
if (result.ok && result.value) {
|
||||
const lease = result.value;
|
||||
const realDispatch = lease.dispatch.bind(lease);
|
||||
const realDispose = lease.dispose.bind(lease);
|
||||
lease.dispatch = (): unknown => {
|
||||
counters.dispatch += 1;
|
||||
order.push('dispatch');
|
||||
return realDispatch();
|
||||
};
|
||||
lease.dispose = (): unknown => {
|
||||
counters.dispose += 1;
|
||||
return realDispose();
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it('orders a legacy browser turn persist -> ack -> lease.dispatch -> prompt, each exactly once (Task 5 G2)', async () => {
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||
const order: string[] = [];
|
||||
const counters = { dispatch: 0, dispose: 0 };
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue(session),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockImplementation(async (): Promise<void> => {
|
||||
order.push('prompt');
|
||||
}),
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'conversation-order-1' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage: vi.fn().mockImplementation(async (): Promise<void> => {
|
||||
order.push('persist');
|
||||
}),
|
||||
},
|
||||
};
|
||||
const router = readyRouter('legacy', agentService, harnessConversations);
|
||||
instrumentLease(router, order, counters);
|
||||
const gateway = new ChatGateway(
|
||||
router as never,
|
||||
{ api: { getSession: vi.fn() } } as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'browser-order-1',
|
||||
data: { user: { id: 'user-a' } },
|
||||
emit: vi.fn().mockImplementation((event: string): void => {
|
||||
if (event === 'message:ack') order.push('ack');
|
||||
}),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
{
|
||||
conversationId: 'conversation-order-1',
|
||||
content: 'ordered hello',
|
||||
} as never,
|
||||
);
|
||||
|
||||
// The Gateway persists the user turn, THEN acks, THEN invokes the one-shot lease dispatch which
|
||||
// finally prompts. Observed at the real lease boundary: dispatch is invoked exactly once and the
|
||||
// runtime prompts exactly once.
|
||||
expect(order).toEqual(['persist', 'ack', 'dispatch', 'prompt']);
|
||||
expect(counters.dispatch).toBe(1);
|
||||
expect(agentService.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a legacy browser turn on persistence failure: zero info/ack/dispatch/prompt, one clean disposal (Task 5 G2)', async () => {
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||
const order: string[] = [];
|
||||
const counters = { dispatch: 0, dispose: 0 };
|
||||
const unsub = vi.fn();
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue(session),
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue(unsub),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const harnessConversations = { append: vi.fn() };
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'conversation-fail-1' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage: vi.fn().mockRejectedValue(new Error('persistence unavailable')),
|
||||
},
|
||||
};
|
||||
const router = readyRouter('legacy', agentService, harnessConversations);
|
||||
instrumentLease(router, order, counters);
|
||||
const gateway = new ChatGateway(
|
||||
router as never,
|
||||
{ api: { getSession: vi.fn() } } as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'browser-fail-1',
|
||||
data: { user: { id: 'user-a' } },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
{
|
||||
conversationId: 'conversation-fail-1',
|
||||
content: 'will fail persistence',
|
||||
} as never,
|
||||
);
|
||||
|
||||
// A failed user-message persistence aborts the turn with no accept-then-lose: no session:info,
|
||||
// no ack, the lease never dispatches or prompts, and the just-prepared lease is disposed exactly
|
||||
// once (listener/channel cleanup) without throwing. The fixed safe error is surfaced.
|
||||
expect(client.emit).not.toHaveBeenCalledWith('session:info', expect.anything());
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
expect(counters.dispatch).toBe(0);
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(counters.dispose).toBe(1);
|
||||
expect(unsub).toHaveBeenCalledTimes(1);
|
||||
expect(client.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ code: 'persist_failed' }),
|
||||
);
|
||||
expect(harnessConversations.append).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chat DTO validation', () => {
|
||||
it('rejects unsupported message roles', () => {
|
||||
const dto = Object.assign(new SendMessageDto(), {
|
||||
|
||||
@@ -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 = '';
|
||||
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
|
||||
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
|
||||
const result = await this.runtime.completeLegacyRestTurn(
|
||||
ownConversation(conversationId, scope),
|
||||
{ content: body.content },
|
||||
);
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content, scope);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
}
|
||||
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
|
||||
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
if (result.ok) {
|
||||
return { conversationId, text: result.value.text };
|
||||
}
|
||||
|
||||
return { conversationId, text: responseText };
|
||||
throw this.toHttpException(result, conversationId);
|
||||
}
|
||||
|
||||
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
|
||||
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
|
||||
switch (failure.code) {
|
||||
case 'conversation_unavailable':
|
||||
return new NotFoundException('Session not found');
|
||||
case 'request_invalid':
|
||||
case 'thinking_level_invalid':
|
||||
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
|
||||
case 'timeout':
|
||||
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
|
||||
case 'runtime_unsupported':
|
||||
case 'runtime_unavailable':
|
||||
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
default:
|
||||
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
|
||||
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,482 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import type {
|
||||
ChatRuntime,
|
||||
LegacyBrowserMessagePayload,
|
||||
LegacyEmbeddedChatPort,
|
||||
LegacyRuntimeEvent,
|
||||
LegacyRuntimeResult,
|
||||
LegacySessionPresentation,
|
||||
LegacySocketTurnLease,
|
||||
LegacyUsage,
|
||||
OwnedConversationContext,
|
||||
VerifiedDiscordIngressContext,
|
||||
VerifiedDiscordTurnLease,
|
||||
LegacyRuntimeStream,
|
||||
} from './chat-runtime.js';
|
||||
|
||||
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
|
||||
const REST_TURN_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
|
||||
*
|
||||
* It owns the embedded in-process execution path — the `AgentService` stack that the
|
||||
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
|
||||
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
|
||||
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
|
||||
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
|
||||
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
|
||||
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
|
||||
* collapses to `conversation_unavailable` and never throws out of the port.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
|
||||
readonly kind = 'embedded' as const;
|
||||
private readonly logger = new Logger(EmbeddedChatRuntime.name);
|
||||
|
||||
constructor(readonly agentService: AgentService) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy REST completion (op A)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async completeLegacyRestTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: Readonly<{ content: string }>,
|
||||
): Promise<
|
||||
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
||||
> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
let responseText = '';
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, REST_TURN_TIMEOUT_MS);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, input.content, scope);
|
||||
await done;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('timed out')) {
|
||||
return { ok: false, code: 'timeout', retryable: true };
|
||||
}
|
||||
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
|
||||
return { ok: false, code: 'operation_failed', retryable: false };
|
||||
}
|
||||
|
||||
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
|
||||
return { ok: true, value: { text: responseText, presentation } };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy Socket streaming (op B)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async prepareLegacySocketTurn(
|
||||
context: OwnedConversationContext,
|
||||
input: LegacyBrowserMessagePayload,
|
||||
stream: LegacyRuntimeStream,
|
||||
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
|
||||
const scope = toScope(context.scope);
|
||||
const { conversationId } = context;
|
||||
|
||||
const resolved = await this.resolveOrCreate(conversationId, scope, {
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
...(input.modelId ? { modelId: input.modelId } : {}),
|
||||
...(input.agentId ? { agentConfigId: input.agentId } : {}),
|
||||
});
|
||||
if (!resolved.ok) return resolved;
|
||||
|
||||
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,
|
||||
});
|
||||
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 }>,
|
||||
): Promise<
|
||||
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
|
||||
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
|
||||
> {
|
||||
let session = this.agentService.getSession(conversationId, scope);
|
||||
if (!session) {
|
||||
try {
|
||||
session = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
...extraOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
|
||||
return CONVERSATION_UNAVAILABLE;
|
||||
}
|
||||
this.logger.error(
|
||||
`Embedded session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
return { ok: false, code: 'runtime_unavailable', retryable: true };
|
||||
}
|
||||
}
|
||||
return { ok: true, presentation: this.presentationForSession(session) };
|
||||
}
|
||||
|
||||
/** Installs a normalizing event listener that forwards to the server-owned stream. */
|
||||
private subscribe(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
stream: LegacyRuntimeStream,
|
||||
): () => void {
|
||||
const unsubscribe = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
const normalized = this.normalizeEvent(conversationId, scope, event);
|
||||
if (normalized) stream.onEvent(normalized);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -12,6 +13,7 @@ const mockRegistry = {
|
||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'prdy', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
@@ -72,7 +74,20 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||
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>;
|
||||
} = mockMcpClient,
|
||||
): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
@@ -82,7 +97,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
mcpClient as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,4 +273,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
});
|
||||
|
||||
// Top-level catch sanitization (P3-4 re-review finding #1): a rejected
|
||||
// Redis `set` inside /provider login is the only reachable path into the
|
||||
// top-level catch in `execute()`. The raw exception must be logged
|
||||
// server-side but never handed back to the socket client.
|
||||
it('sanitizes the top-level command catch, logging the raw exception but never returning it to the client', async () => {
|
||||
const distinctiveRawFailure = 'ECONNREFUSED distinctive-raw-redis-failure-token-9f31';
|
||||
const rawError = new Error(distinctiveRawFailure);
|
||||
const failingRedis = {
|
||||
set: vi.fn().mockRejectedValue(rawError),
|
||||
get: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
const failingService = buildService(failingRedis as unknown as typeof mockRedis);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await failingService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toBe('Command failed due to an internal error.');
|
||||
expect(result.message).not.toContain(distinctiveRawFailure);
|
||||
expect(result.message).not.toContain('ECONNREFUSED');
|
||||
|
||||
// The real exception is still logged server-side, as the raw Error
|
||||
// object itself (not stringified/interpolated into the log message).
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(rawError));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Inner catch sanitization (P3-5 operator ruling): every catch in
|
||||
// command-executor.service.ts that returns a SlashCommandResultPayload
|
||||
// must sanitize the client-facing message the same way the top-level
|
||||
// catch does, while still logging the raw exception server-side.
|
||||
it('/agent new sanitizes agent-creation failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-create-failure-token-A17f');
|
||||
mockBrain.agents.create.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'new my-new-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to create agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-create-failure-token-A17f');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/agent <name> switch sanitizes agent-lookup failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-agent-switch-failure-token-B29c');
|
||||
mockBrain.agents.findByName.mockRejectedValueOnce(marker);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'agent',
|
||||
args: 'some-other-agent',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toBe('Failed to switch agent due to an internal error.');
|
||||
expect(result.message).not.toContain('distinctive-agent-switch-failure-token-B29c');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('/mcp reconnect sanitizes MCP client failures, logging the raw exception but never returning it to the client', async () => {
|
||||
const marker = new Error('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockRejectedValue(marker),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
const mcpService = buildService(mockRedis, mockMcpClient);
|
||||
const loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'mcp',
|
||||
args: 'reconnect my-server',
|
||||
conversationId,
|
||||
};
|
||||
const result = await mcpService.execute(payload, userScope);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.command).toBe('mcp');
|
||||
expect(result.message).toBe(
|
||||
'Failed to reconnect MCP server "my-server" due to an internal error.',
|
||||
);
|
||||
expect(result.message).not.toContain('distinctive-mcp-reconnect-failure-token-C33e');
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalled();
|
||||
const loggedRawError = loggerErrorSpy.mock.calls.some((call) => call.includes(marker));
|
||||
expect(loggedRawError).toBe(true);
|
||||
|
||||
loggerErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -159,8 +157,13 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
this.logger.error(`Command /${command} failed`, err);
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'Command failed due to an internal error.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,11 +339,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create agent: ${err}`);
|
||||
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to create agent: ${String(err)}`,
|
||||
message: 'Failed to create agent due to an internal error.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -391,11 +394,11 @@ export class CommandExecutorService {
|
||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
||||
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||
return {
|
||||
command: 'agent',
|
||||
success: false,
|
||||
message: `Failed to switch agent: ${String(err)}`,
|
||||
message: 'Failed to switch agent due to an internal error.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
@@ -543,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) {
|
||||
@@ -608,11 +602,12 @@ export class CommandExecutorService {
|
||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
message: `Failed to reconnect MCP server "${serverName}" due to an internal error.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,44 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { McpClientService } from './mcp-client.service.js';
|
||||
|
||||
const MCP_LEAK_MARKER = 'MCP_LEAK_MARKER /srv/secret';
|
||||
|
||||
describe('McpClientService — failed connect error sanitization', () => {
|
||||
const originalMcpServers = process.env['MCP_SERVERS'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['MCP_SERVERS'] = JSON.stringify([
|
||||
{ name: 'leaky-server', url: 'http://localhost:9999/mcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalMcpServers === undefined) {
|
||||
delete process.env['MCP_SERVERS'];
|
||||
} else {
|
||||
process.env['MCP_SERVERS'] = originalMcpServers;
|
||||
}
|
||||
});
|
||||
|
||||
it('stores a generic serverEntry.error while logging the raw exception server-side', async () => {
|
||||
vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error(MCP_LEAK_MARKER));
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const service = new McpClientService();
|
||||
await service.onModuleInit();
|
||||
|
||||
const statuses = service.getServerStatuses();
|
||||
expect(statuses).toHaveLength(1);
|
||||
expect(statuses[0]?.connected).toBe(false);
|
||||
expect(statuses[0]?.error).toBe('Connection failed (see server logs).');
|
||||
expect(statuses[0]?.error).not.toContain(MCP_LEAK_MARKER);
|
||||
|
||||
const loggedRawMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(MCP_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedRawMarker).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -189,7 +189,7 @@ export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
serverEntry.error = message;
|
||||
serverEntry.error = 'Connection failed (see server logs).';
|
||||
serverEntry.connected = false;
|
||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||
}
|
||||
|
||||
@@ -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,193 @@ 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',
|
||||
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(undefined);
|
||||
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',
|
||||
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(undefined);
|
||||
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('retains validated persisted attachments in resumed conversation history', async () => {
|
||||
@@ -593,6 +838,7 @@ describe('Discord ingress security', () => {
|
||||
|
||||
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||
const addMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const session = {
|
||||
@@ -618,8 +864,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 +914,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', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||
import { ReloadService } from './reload.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
|
||||
function createMockCommandRegistry() {
|
||||
return {
|
||||
@@ -104,3 +107,85 @@ describe('ReloadService', () => {
|
||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
it('generic per-plugin errors reach the chat surface while raw markers stay server-side only', async () => {
|
||||
const registry = {
|
||||
getManifest: vi.fn().mockReturnValue({
|
||||
version: 1,
|
||||
commands: [
|
||||
{ name: 'reload', aliases: [], scope: 'core', execution: 'socket', available: true },
|
||||
],
|
||||
skills: [],
|
||||
}),
|
||||
};
|
||||
const reloadService = new ReloadService(registry as never);
|
||||
|
||||
const RELOAD_LOAD_LEAK_MARKER = 'RELOAD_LOAD_LEAK_MARKER /srv/load-secret';
|
||||
const RELOAD_UNLOAD_LEAK_MARKER = 'RELOAD_UNLOAD_LEAK_MARKER /srv/unload-secret';
|
||||
|
||||
reloadService.registerPlugin('unload-fails', {
|
||||
pluginName: 'unload-fails',
|
||||
onLoad: vi.fn().mockResolvedValue(undefined),
|
||||
onUnload: vi.fn().mockRejectedValue(new Error(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
});
|
||||
reloadService.registerPlugin('load-fails', {
|
||||
pluginName: 'load-fails',
|
||||
onLoad: vi.fn().mockRejectedValue(new Error(RELOAD_LOAD_LEAK_MARKER)),
|
||||
onUnload: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const broadcastReload = vi.fn();
|
||||
const mockChatGateway = { broadcastReload };
|
||||
const mockAgentService = { getSession: vi.fn(), applyAgentConfig: vi.fn() };
|
||||
const mockSystemOverride = { set: vi.fn(), get: vi.fn(), clear: vi.fn() };
|
||||
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,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
null,
|
||||
mockBrain as never,
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
mockMcpClient as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
const result = await executor.execute(payload, { userId: 'user-1', tenantId: 'user-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(result.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(result.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(result.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
expect(broadcastReload).toHaveBeenCalledOnce();
|
||||
const broadcastPayload = broadcastReload.mock.calls[0]?.[0] as SystemReloadPayload;
|
||||
expect(broadcastPayload.message).toContain('unload-fails: unload failed (internal error)');
|
||||
expect(broadcastPayload.message).toContain('load-fails: load failed (internal error)');
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_UNLOAD_LEAK_MARKER);
|
||||
expect(broadcastPayload.message).not.toContain(RELOAD_LOAD_LEAK_MARKER);
|
||||
|
||||
const loggedUnloadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_UNLOAD_LEAK_MARKER)),
|
||||
);
|
||||
const loggedLoadMarker = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes(RELOAD_LOAD_LEAK_MARKER)),
|
||||
);
|
||||
expect(loggedUnloadMarker).toBe(true);
|
||||
expect(loggedLoadMarker).toBe(true);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
await plugin.onUnload();
|
||||
reloaded.push(name);
|
||||
} catch (err) {
|
||||
errors.push(`${name}: unload failed — ${err}`);
|
||||
this.logger.error(`Plugin "${name}" failed during onUnload: ${err}`);
|
||||
errors.push(`${name}: unload failed (internal error)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,8 @@ export class ReloadService implements OnApplicationBootstrap, OnApplicationShutd
|
||||
try {
|
||||
await plugin.onLoad();
|
||||
} catch (err) {
|
||||
errors.push(`${name}: load failed — ${err}`);
|
||||
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||
errors.push(`${name}: load failed (internal error)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
"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:^",
|
||||
"@mosaicstack/types": "workspace:^",
|
||||
"better-auth": "^1.5.5",
|
||||
"clsx": "^2.1.0",
|
||||
"next": "^16.0.0",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Centralizes the type-only import of the shared `/chat` Socket.IO contract from
|
||||
// the public `@mosaicstack/types` package. `import type` is erased at compile
|
||||
// time, so this introduces no runtime dependency — it only reuses the exact
|
||||
// payload shapes instead of redeclaring them.
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export type {
|
||||
AbortPayload,
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
AgentTextPayload,
|
||||
AgentThinkingPayload,
|
||||
ChatMessagePayload,
|
||||
ChatSendCapabilityPayload,
|
||||
ChatSendProtocol,
|
||||
ClientToServerEvents,
|
||||
CommandDef,
|
||||
CommandManifest,
|
||||
CommandManifestPayload,
|
||||
ErrorPayload,
|
||||
HarnessSelection,
|
||||
HarnessTurnAckPayload,
|
||||
HarnessTurnSendPayload,
|
||||
MessageAckPayload,
|
||||
RoutingDecisionInfo,
|
||||
ServerToClientEvents,
|
||||
SessionInfoPayload,
|
||||
SessionUsagePayload,
|
||||
SetThinkingPayload,
|
||||
SkillCommandDef,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandPayload,
|
||||
SlashCommandResultPayload,
|
||||
SystemReloadPayload,
|
||||
ToolEndPayload,
|
||||
ToolStartPayload,
|
||||
};
|
||||
|
||||
/** The `/chat` namespace socket, narrowed to the exact typed event contract. */
|
||||
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
@@ -10,30 +10,52 @@ vi.mock('socket.io-client', () => ({
|
||||
|
||||
import { destroySocket, getSocket } from './socket';
|
||||
|
||||
interface MockChatSocket {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
offAny: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
/** Test-only helper: fires every handler registered for `event` via
|
||||
* `.on`, mirroring how a real socket.io-client instance invokes its own
|
||||
* listeners (e.g. calling the registered `disconnect` handler(s) on a
|
||||
* real transient disconnect). */
|
||||
trigger(event: string): void;
|
||||
}
|
||||
|
||||
function createMockSocket(): MockChatSocket {
|
||||
const handlers = new Map<string, Set<() => void>>();
|
||||
const mockSocket: MockChatSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||
handlers.get(event)?.add(handler);
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
trigger(event: string): void {
|
||||
for (const handler of handlers.get(event) ?? []) handler();
|
||||
},
|
||||
};
|
||||
return mockSocket;
|
||||
}
|
||||
|
||||
let currentMock!: MockChatSocket;
|
||||
|
||||
describe('chat socket', () => {
|
||||
let disconnectHandler: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
disconnectHandler = undefined;
|
||||
ioMock.mockReset();
|
||||
|
||||
const mockSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'disconnect') disconnectHandler = handler;
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
};
|
||||
|
||||
ioMock.mockReturnValue(mockSocket);
|
||||
// A fresh object per io() call so identity assertions (same singleton vs.
|
||||
// a genuinely new instance) are meaningful.
|
||||
ioMock.mockImplementation(() => {
|
||||
currentMock = createMockSocket();
|
||||
return currentMock;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroySocket();
|
||||
});
|
||||
|
||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
||||
it('creates one same-origin /chat namespace socket', () => {
|
||||
const first = getSocket();
|
||||
const second = getSocket();
|
||||
|
||||
@@ -44,9 +66,33 @@ describe('chat socket', () => {
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
});
|
||||
|
||||
disconnectHandler?.();
|
||||
getSocket();
|
||||
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||
const first = getSocket();
|
||||
|
||||
// socket.ts must not react to a real socket's `disconnect` event by
|
||||
// nulling the singleton — it registers no such handler at all now.
|
||||
// Actually fire every handler registered via `.on('disconnect', ...)`
|
||||
// (mirroring a real socket.io-client reconnect) instead of merely
|
||||
// calling getSocket() again: this is what makes the test fail if
|
||||
// production reintroduces `socket.on('disconnect', () => { socket =
|
||||
// null; })`, since that handler would run here and null the singleton
|
||||
// before the next getSocket() call.
|
||||
currentMock.trigger('disconnect');
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('only creates a new singleton after an explicit destroySocket()', () => {
|
||||
const first = getSocket();
|
||||
|
||||
destroySocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+16
-10
@@ -1,21 +1,27 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { io } from 'socket.io-client';
|
||||
import type { ChatSocket } from './chat-contract';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
let socket: ChatSocket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
export function getSocket(): ChatSocket {
|
||||
if (!socket) {
|
||||
// socket.io-client 4.8.3's `io()` factory declaration always returns the
|
||||
// default unparameterized Socket (it accepts no <ListenEvents, EmitEvents>
|
||||
// generics), so this one cast is the unavoidable boundary between that and the
|
||||
// typed `/chat` contract. Every other call site uses the resulting ChatSocket
|
||||
// with no further assertions.
|
||||
socket = io('/chat', {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
}) as unknown as ChatSocket;
|
||||
|
||||
// Reset singleton reference when socket is fully closed so the next
|
||||
// getSocket() call creates a fresh instance instead of returning a
|
||||
// closed/dead socket.
|
||||
socket.on('disconnect', () => {
|
||||
socket = null;
|
||||
});
|
||||
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||
// and its listeners stay registered across that reconnect. Nulling here
|
||||
// previously orphaned those listeners on the next getSocket() call by
|
||||
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||
// (an explicit, intentional teardown) may reset the singleton.
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-4
@@ -3,6 +3,16 @@ import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-r
|
||||
import { LoginPage } from '@/spa/pages/login';
|
||||
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';
|
||||
|
||||
@@ -34,10 +44,18 @@ export const routes: RouteObject[] = [
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <Placeholder title="Chat" /> },
|
||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||
{
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { CommandsPanel } from './commands-panel';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('CommandsPanel', () => {
|
||||
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
// Free-text server message claims a different, less alarming target
|
||||
// than what will actually be sent — the UI must not rely on this.
|
||||
message: 'This will only affect the staging environment.',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The exact frozen combined action is visible...
|
||||
expect(container?.textContent).toContain('/deploy');
|
||||
expect(container?.textContent).toContain('prod');
|
||||
// ...and the misleading server free-text is never shown next to it.
|
||||
expect(container?.textContent).not.toContain('staging environment');
|
||||
});
|
||||
|
||||
it('does not throw when a manifest commands entry is null', async () => {
|
||||
const manifest = {
|
||||
commands: [
|
||||
null,
|
||||
{
|
||||
name: 'model',
|
||||
aliases: [],
|
||||
description: 'Change the active model',
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [null],
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container?.textContent).toContain('model');
|
||||
});
|
||||
|
||||
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: true,
|
||||
approvalId: 'ap1',
|
||||
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
||||
});
|
||||
|
||||
it('renders skills from a skills-only manifest', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={{
|
||||
commands: [],
|
||||
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
||||
version: 1,
|
||||
}}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('brave-search');
|
||||
expect(container?.textContent).toContain('Search the web');
|
||||
});
|
||||
|
||||
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
||||
const approval = {
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: { truthy: 'object' },
|
||||
approvalId: { also: 'object' },
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
||||
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={approval}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
||||
button.textContent?.includes('Run approved command'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{
|
||||
conversationId: 'c1',
|
||||
command: 'deploy',
|
||||
success: false,
|
||||
message: 'Not authorized',
|
||||
}}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Not authorized');
|
||||
});
|
||||
|
||||
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[]}
|
||||
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
||||
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
||||
hasConversation
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Denied.');
|
||||
});
|
||||
|
||||
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
||||
{ conversationId: 'c1', command: 'deploy', success: false },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container?.textContent).toContain('Unknown model');
|
||||
expect(container?.textContent).toContain('Command failed.');
|
||||
});
|
||||
|
||||
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
||||
const hostileMessage = 'y'.repeat(50_000);
|
||||
await render(
|
||||
<CommandsPanel
|
||||
manifest={null}
|
||||
results={[
|
||||
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
||||
]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = container?.textContent ?? '';
|
||||
expect(text.length).toBeLessThan(hostileMessage.length);
|
||||
});
|
||||
|
||||
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
||||
const manifest = {
|
||||
commands: 'not-an-array',
|
||||
skills: null,
|
||||
version: 1,
|
||||
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
||||
|
||||
await expect(
|
||||
render(
|
||||
<CommandsPanel
|
||||
manifest={manifest}
|
||||
results={[]}
|
||||
approval={null}
|
||||
pendingApproval={null}
|
||||
hasConversation={false}
|
||||
onExecute={vi.fn()}
|
||||
onApprove={vi.fn()}
|
||||
onRunApproved={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import type { PendingApproval } from './use-chat-connection';
|
||||
import { MAX_COMMAND_MESSAGE_CHARS } from './limits';
|
||||
import { asNonEmptyString, asString } from './runtime-guards';
|
||||
import type {
|
||||
CommandManifest,
|
||||
SlashCommandApprovalResultPayload,
|
||||
SlashCommandResultPayload,
|
||||
} from '@/lib/chat-contract';
|
||||
|
||||
/** Stable fallback copy shown for a failed command only when the server's
|
||||
* own guarded, non-empty `message` (e.g. "Unknown model") is absent or
|
||||
* malformed — the structured contract reason itself is otherwise shown
|
||||
* directly, never a raw thrown exception, stack trace, or object value. */
|
||||
const COMMAND_FAILURE_COPY = 'Command failed.';
|
||||
|
||||
/** Render-site defense-in-depth: `use-chat-connection.ts` already bounds a
|
||||
* stored command:result message at ingestion, but this component must never
|
||||
* assume every caller went through that path — bounding again here means a
|
||||
* hostile/oversized message can never force an unbounded render. */
|
||||
function boundMessage(value: string): string {
|
||||
return value.length > MAX_COMMAND_MESSAGE_CHARS
|
||||
? value.slice(0, MAX_COMMAND_MESSAGE_CHARS)
|
||||
: value;
|
||||
}
|
||||
|
||||
interface CommandsPanelProps {
|
||||
manifest: CommandManifest | null;
|
||||
results: SlashCommandResultPayload[];
|
||||
approval: SlashCommandApprovalResultPayload | null;
|
||||
pendingApproval: PendingApproval | null;
|
||||
hasConversation: boolean;
|
||||
onExecute: (input: { command: string; args?: string }) => void;
|
||||
onApprove: (input: { command: string; args?: string }) => void;
|
||||
onRunApproved: () => void;
|
||||
}
|
||||
|
||||
export function CommandsPanel({
|
||||
manifest,
|
||||
results,
|
||||
approval,
|
||||
pendingApproval,
|
||||
hasConversation,
|
||||
onExecute,
|
||||
onApprove,
|
||||
onRunApproved,
|
||||
}: CommandsPanelProps): ReactElement {
|
||||
const [command, setCommand] = useState('');
|
||||
const [args, setArgs] = useState('');
|
||||
|
||||
// Defense-in-depth: the reducer already normalizes success/approvalId
|
||||
// before storing `approval`, but a matching command string alone must
|
||||
// never be trusted here either — require the literal boolean `true` and a
|
||||
// non-empty string approvalId, not merely truthy values.
|
||||
const canRunApproved =
|
||||
approval?.success === true &&
|
||||
typeof approval.approvalId === 'string' &&
|
||||
approval.approvalId.length > 0 &&
|
||||
!!pendingApproval &&
|
||||
pendingApproval.command === approval.command;
|
||||
|
||||
// A manifest arrives from the server as untyped JSON at runtime — guard
|
||||
// both collections before mapping so a malformed manifest cannot throw.
|
||||
const commands = Array.isArray(manifest?.commands) ? manifest.commands : [];
|
||||
const skills = Array.isArray(manifest?.skills) ? manifest.skills : [];
|
||||
|
||||
return (
|
||||
<section aria-label="Commands" className="flex flex-col gap-2 border-b px-4 py-3 text-xs">
|
||||
{commands.length > 0 ? (
|
||||
<ul aria-label="Available commands" className="flex flex-col gap-1">
|
||||
{commands.map((cmd, index) => (
|
||||
<li key={asString(cmd?.name) || `cmd-${index}`}>
|
||||
<strong>/{asString(cmd?.name)}</strong> — {asString(cmd?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{skills.length > 0 ? (
|
||||
<ul aria-label="Available skills" className="flex flex-col gap-1">
|
||||
{skills.map((skill, index) => (
|
||||
<li key={asString(skill?.name) || `skill-${index}`}>
|
||||
<strong>/skill:{asString(skill?.name)}</strong> — {asString(skill?.description)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Command name"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="command"
|
||||
/>
|
||||
<input
|
||||
aria-label="Command arguments"
|
||||
value={args}
|
||||
onChange={(event) => setArgs(event.target.value)}
|
||||
placeholder="args (optional)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onExecute({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Run command
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConversation || !command.trim()}
|
||||
onClick={() => onApprove({ command: command.trim(), args: args.trim() || undefined })}
|
||||
>
|
||||
Request approval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{approval ? (
|
||||
<div role={approval.success ? 'status' : 'alert'} className="flex items-center gap-2">
|
||||
{/* A successful approval shows stable client copy only — never
|
||||
the server-controlled approval.message or echoed
|
||||
approval.command as the primary confirmation. The frozen local
|
||||
pendingApproval below (not this line) is the sole authoritative
|
||||
statement of what will run. A denial, by contrast, is not an
|
||||
execution authority and safely surfaces the guarded structured
|
||||
reason the server gave (e.g. "Not authorized"), falling back to
|
||||
a stable copy only when absent/malformed. */}
|
||||
<span>
|
||||
{approval.success ? 'Approved.' : asNonEmptyString(approval.message, 'Denied.')}
|
||||
</span>
|
||||
{canRunApproved && pendingApproval ? (
|
||||
<>
|
||||
{/* Authoritative frozen local command+args — what the click below
|
||||
will actually emit. The server's `approval` above is display-only
|
||||
and must never be trusted to represent the executed payload. */}
|
||||
<span>
|
||||
Will run: /{pendingApproval.command}{' '}
|
||||
{pendingApproval.args ? pendingApproval.args : '(no args)'}
|
||||
</span>
|
||||
<button type="button" onClick={onRunApproved}>
|
||||
Run approved command
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results.length > 0 ? (
|
||||
<ul aria-label="Command results" className="flex flex-col gap-1">
|
||||
{results.map((result, index) => (
|
||||
<li key={`${result.command}-${index}`} role={result.success ? 'status' : 'alert'}>
|
||||
/{asString(result.command)}: {result.success ? 'success' : 'failed'}
|
||||
{result.success
|
||||
? typeof result.message === 'string' && result.message
|
||||
? ` — ${boundMessage(result.message)}`
|
||||
: ''
|
||||
: ` — ${boundMessage(asNonEmptyString(result.message, COMMAND_FAILURE_COPY))}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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; selection: HarnessSelection }) => boolean;
|
||||
onStop: () => void;
|
||||
streaming: boolean;
|
||||
/** True from local send time through server turn startup/ack and
|
||||
* throughout streaming — a superset of `streaming` that also covers the
|
||||
* 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({
|
||||
onSend,
|
||||
onStop,
|
||||
streaming,
|
||||
sending,
|
||||
hasConversation,
|
||||
harness,
|
||||
}: ComposerProps): ReactElement {
|
||||
const [content, setContent] = 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;
|
||||
// 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 {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="flex flex-col gap-2 border-t p-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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={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"
|
||||
>
|
||||
<option value="">Select a provider…</option>
|
||||
{providerOptions(harness).map((providerId) => (
|
||||
<option key={providerId} value={providerId}>
|
||||
{providerId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Model"
|
||||
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"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
placeholder="Message… (Enter to send, Shift+Enter for a new line)"
|
||||
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || busy || !harness.canSend}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Stop"
|
||||
disabled={!hasConversation || !streaming}
|
||||
onClick={onStop}
|
||||
className="rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Bounds on server-fed chat state. A hostile or malfunctioning gateway can
|
||||
* flood any of these collections; caps keep memory/render cost flat instead
|
||||
* of growing unboundedly for the lifetime of the connection.
|
||||
*/
|
||||
|
||||
/** Max characters retained for the in-flight streamed text/thinking buffers. */
|
||||
export const MAX_STREAM_CHARS = 20_000;
|
||||
/** Max transcript turns retained (oldest dropped first). */
|
||||
export const MAX_MESSAGES = 500;
|
||||
/** Max tool-call entries (including anomaly entries) retained per turn history. */
|
||||
export const MAX_TOOLS = 200;
|
||||
/** Max slash-command results retained. */
|
||||
export const MAX_COMMAND_RESULTS = 200;
|
||||
/** Max commands/skills accepted from a single manifest push. */
|
||||
export const MAX_MANIFEST_ITEMS = 500;
|
||||
/** Max executed approval IDs remembered for single-flight dedup. */
|
||||
export const MAX_EXECUTED_APPROVAL_IDS = 200;
|
||||
/** Max characters retained for a single command:result message — a hostile
|
||||
* or malfunctioning gateway must not be able to push an unbounded curated
|
||||
* success/failure reason into state (or, defensively, onto the page). */
|
||||
export const MAX_COMMAND_MESSAGE_CHARS = 1_000;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ChatTranscriptMessage } from './use-chat-connection';
|
||||
|
||||
interface MessageTranscriptProps {
|
||||
messages: ChatTranscriptMessage[];
|
||||
streaming: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MessageTranscript({
|
||||
messages,
|
||||
streaming,
|
||||
text,
|
||||
}: MessageTranscriptProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Conversation"
|
||||
className="flex flex-1 flex-col gap-3 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} data-role={message.role} className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">{message.role === 'user' ? 'You' : 'Assistant'}: </span>
|
||||
<span>{message.text}</span>
|
||||
{message.thinking ? (
|
||||
<div className="pt-1 text-xs italic opacity-70">{message.thinking}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{streaming ? (
|
||||
<div data-role="assistant-streaming" className="whitespace-pre-wrap text-sm">
|
||||
<span className="font-medium">Assistant: </span>
|
||||
<span>{text || 'Thinking…'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Socket.IO payloads are only statically typed at the call site — a
|
||||
* misbehaving or compromised gateway can send anything at runtime. These
|
||||
* guards protect the dereference sites that would otherwise throw (`.map` on
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Like `asString`, but an empty string also falls back — used for guarded
|
||||
* contract-provided reason strings (e.g. a denial or failure message) where
|
||||
* an empty string is not a meaningful value to display in place of the
|
||||
* stable fallback copy. */
|
||||
export function asNonEmptyString(value: unknown, fallback: string): string {
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asFiniteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/** Like `asFiniteNumber`, but returns `null` on failure instead of a numeric
|
||||
* fallback — callers that must not fabricate a plausible-looking value (e.g.
|
||||
* `0 tokens` / `$0.0000` for genuinely unknown usage) use this to render an
|
||||
* honest "unavailable" label instead. */
|
||||
export function asFiniteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
|
||||
}
|
||||
|
||||
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
|
||||
* `state.conversationId` against a raw socket payload must route through
|
||||
* this guard so a malformed first frame (null/object/number/empty string)
|
||||
* can never be adopted verbatim. */
|
||||
export function asConversationId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { SessionInfoPayload } from '@/lib/chat-contract';
|
||||
import { MAX_MANIFEST_ITEMS } from './limits';
|
||||
import { asString, asStringArray } from './runtime-guards';
|
||||
|
||||
interface SessionPanelProps {
|
||||
sessionInfo: SessionInfoPayload | null;
|
||||
onSetThinking: (level: string) => void;
|
||||
}
|
||||
|
||||
const THINKING_LEVEL_UNAVAILABLE = '';
|
||||
|
||||
export function SessionPanel({
|
||||
sessionInfo,
|
||||
onSetThinking,
|
||||
}: SessionPanelProps): ReactElement | null {
|
||||
if (!sessionInfo) return null;
|
||||
|
||||
// The reducer already caps this before storing it, but the render site
|
||||
// defends independently — a hostile payload must never be able to force
|
||||
// this <select> to lay out an unbounded number of options.
|
||||
const availableThinkingLevels = asStringArray(sessionInfo.availableThinkingLevels).slice(
|
||||
0,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
);
|
||||
const hasThinkingLevels = availableThinkingLevels.length > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Session info"
|
||||
className="flex flex-wrap items-center gap-3 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span>{asString(sessionInfo.provider, 'unknown')}</span>
|
||||
<span>{asString(sessionInfo.modelId, 'unknown')}</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>Thinking level</span>
|
||||
<select
|
||||
aria-label="Thinking level"
|
||||
value={
|
||||
hasThinkingLevels ? asString(sessionInfo.thinkingLevel) : THINKING_LEVEL_UNAVAILABLE
|
||||
}
|
||||
onChange={(event) => {
|
||||
// The placeholder option is not a real, settable level — a
|
||||
// malformed availableThinkingLevels list must never let the
|
||||
// client emit set:thinking for it.
|
||||
if (!hasThinkingLevels) return;
|
||||
onSetThinking(event.target.value);
|
||||
}}
|
||||
>
|
||||
{hasThinkingLevels ? (
|
||||
availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={THINKING_LEVEL_UNAVAILABLE}>Thinking level unavailable</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{sessionInfo.routingDecision ? (
|
||||
<span title={asString(sessionInfo.routingDecision.ruleName)}>
|
||||
{asString(sessionInfo.routingDecision.reason)}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ClientToServerEvents, ServerToClientEvents } from '@/lib/chat-contract';
|
||||
|
||||
type ServerEvent = keyof ServerToClientEvents;
|
||||
type ClientEvent = keyof ClientToServerEvents;
|
||||
type ServerHandler<K extends ServerEvent> = ServerToClientEvents[K];
|
||||
type ClientPayload<K extends ClientEvent> = Parameters<ClientToServerEvents[K]>[0];
|
||||
|
||||
export interface EmittedEvent<K extends ClientEvent = ClientEvent> {
|
||||
event: K;
|
||||
payload: ClientPayload<K>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
emit<K extends ClientEvent>(event: K, payload: ClientPayload<K>): FakeChatSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed in-memory stand-in for `getSocket()`. Unlike a bare
|
||||
* `(event: string, payload: unknown) => void` mock, every public method here is
|
||||
* checked against the real `/chat` contract — a typo'd event name or a payload
|
||||
* missing a required field fails to compile instead of silently no-op'ing at
|
||||
* runtime.
|
||||
*/
|
||||
/** Socket.IO's built-in connection-state events. Not part of the app-level
|
||||
* ServerToClientEvents contract, but real sockets always support them and
|
||||
* `useChatConnection` registers a `disconnect` handler on the real socket. */
|
||||
type LifecycleEvent = 'connect' | 'disconnect';
|
||||
|
||||
export function createFakeChatSocket(): {
|
||||
socket: FakeChatSocket;
|
||||
listeners: Map<ServerEvent, Set<(payload: never) => void>>;
|
||||
emitted: EmittedEvent[];
|
||||
serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void;
|
||||
/** Escape hatch for malformed-payload tests: bypasses the compile-time
|
||||
* payload contract to simulate a genuinely untrusted runtime value from the
|
||||
* server, e.g. a `session:info` with a non-array `availableThinkingLevels`. */
|
||||
serverEmitRaw(event: ServerEvent, payload: unknown): void;
|
||||
/** Simulates a transient Socket.IO `disconnect` — fires any handler(s)
|
||||
* registered via `socket.on('disconnect', ...)` without clearing any
|
||||
* listeners, mirroring how a real reconnecting socket behaves. */
|
||||
simulateDisconnect(): void;
|
||||
/** 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. 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[] = [];
|
||||
|
||||
// Internal storage is intentionally keyed loosely (the per-event handler shape
|
||||
// varies by K, which a single Map can't express); the generic signatures on the
|
||||
// exported `socket`/`serverEmit` above and below are what keep test call sites
|
||||
// type-checked against ServerToClientEvents/ClientToServerEvents.
|
||||
const socket = {
|
||||
connected: false,
|
||||
id: 'socket-a',
|
||||
connect: vi.fn(function connect(this: void) {
|
||||
socket.connected = true;
|
||||
return socket;
|
||||
}),
|
||||
on: vi.fn(function on(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||
listeners.get(event)?.add(handler);
|
||||
return socket;
|
||||
}),
|
||||
off: vi.fn(function off(this: void, event: ServerEvent, handler: (payload: never) => void) {
|
||||
listeners.get(event)?.delete(handler);
|
||||
return socket;
|
||||
}),
|
||||
emit: vi.fn(function emit(this: void, event: ClientEvent, payload: unknown) {
|
||||
emitted.push({ event, payload } as EmittedEvent);
|
||||
return socket;
|
||||
}),
|
||||
} as unknown as FakeChatSocket;
|
||||
|
||||
function serverEmit<K extends ServerEvent>(
|
||||
event: K,
|
||||
payload: Parameters<ServerToClientEvents[K]>[0],
|
||||
): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: Parameters<ServerToClientEvents[K]>[0]) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function serverEmitRaw(event: ServerEvent, payload: unknown): void {
|
||||
for (const handler of listeners.get(event) ?? []) {
|
||||
(handler as (payload: unknown) => void)(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function simulateDisconnect(): void {
|
||||
socket.connected = false;
|
||||
const lifecycleKey = 'disconnect' satisfies LifecycleEvent as unknown as ServerEvent;
|
||||
for (const handler of listeners.get(lifecycleKey) ?? []) {
|
||||
(handler as () => 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)();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
listeners,
|
||||
emitted,
|
||||
serverEmit,
|
||||
serverEmitRaw,
|
||||
simulateDisconnect,
|
||||
simulateReconnect,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { ToolCallList } from './tool-call-list';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement | null;
|
||||
|
||||
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ToolCallList', () => {
|
||||
it('renders two entries independently, without a duplicate-key warning, when a valid toolCallId is shared', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await render(
|
||||
<ToolCallList
|
||||
tools={[
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'success' },
|
||||
{ toolCallId: 'dup', toolName: 'search', status: 'running' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = [...(container?.querySelectorAll('li') ?? [])];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.textContent).toContain('success');
|
||||
expect(items[1]?.textContent).toContain('running');
|
||||
|
||||
const duplicateKeyWarning = consoleError.mock.calls.some((args) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||
);
|
||||
expect(duplicateKeyWarning).toBe(false);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ToolCallState } from './use-chat-connection';
|
||||
|
||||
export function ToolCallList({ tools }: { tools: ToolCallState[] }): ReactElement | null {
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul aria-label="Tool calls" className="flex flex-col gap-1 px-4 pb-2 text-xs">
|
||||
{tools.map((tool, index) => (
|
||||
<li
|
||||
// A valid server-controlled toolCallId can legitimately repeat
|
||||
// (e.g. two tool:start events sharing one id) — keying on it alone
|
||||
// would give React two identical keys. Pairing it with its
|
||||
// (stable, append-only) render index keeps every key unique.
|
||||
key={`${tool.toolCallId}-${index}`}
|
||||
role={tool.status === 'error' || tool.status === 'anomaly' ? 'alert' : 'status'}
|
||||
>
|
||||
{tool.toolName} —{' '}
|
||||
{tool.status === 'anomaly' ? 'unexpected end (unknown tool call)' : tool.status}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
function Boom(): never {
|
||||
throw new Error('render blew up');
|
||||
}
|
||||
|
||||
/** Recursively clones the real exported route table, replacing only the
|
||||
* `/chat` route's `element` with `<Boom />` — every other route (including
|
||||
* the real `AuthGuard` nesting and the real `/chat` `errorElement`) is left
|
||||
* exactly as exported. This is what makes the test fail if a future change
|
||||
* removes the real route's `errorElement`, unlike a hand-built independent
|
||||
* route tree that could drift from production undetected. */
|
||||
function replaceChatElementWithBoom(nodes: RouteObject[]): RouteObject[] {
|
||||
return nodes.map((node) => {
|
||||
const cloned: RouteObject = { ...node };
|
||||
if (cloned.path === '/chat') {
|
||||
cloned.element = <Boom />;
|
||||
}
|
||||
if (cloned.children) {
|
||||
cloned.children = replaceChatElementWithBoom(cloned.children);
|
||||
}
|
||||
return cloned;
|
||||
});
|
||||
}
|
||||
|
||||
let root: Root | null;
|
||||
let container: HTMLElement;
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ChatRouteErrorBoundary', () => {
|
||||
it('renders a recoverable, non-blank fallback when the /chat route element throws during render', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const routeObjects = replaceChatElementWithBoom(routes);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: ['/chat'] });
|
||||
|
||||
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,22 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* `/chat` renders live, server-driven state (streamed text, tool calls,
|
||||
* manifests) that can carry malformed payloads no compile-time contract can
|
||||
* fully rule out at every dereference site. This is the last line of
|
||||
* defense: if something still throws during render, show a recoverable
|
||||
* alert instead of leaving the user on a blank/white screen.
|
||||
*/
|
||||
export function ChatRouteErrorBoundary(): 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">Something went wrong loading chat.</p>
|
||||
<a href="/chat" className="text-sm underline">
|
||||
Reload chat
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { CommandsPanel } from '@/spa/chat/commands-panel';
|
||||
import { Composer } from '@/spa/chat/composer';
|
||||
import { MessageTranscript } from '@/spa/chat/message-transcript';
|
||||
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
|
||||
* an unknown token count must never look the same. */
|
||||
function formatTokens(value: unknown): string {
|
||||
const tokens = asFiniteNumberOrNull(value);
|
||||
return tokens === null ? 'tokens unavailable' : `${tokens} tokens`;
|
||||
}
|
||||
|
||||
/** Same honesty guarantee as `formatTokens`, for cost. */
|
||||
function formatCost(value: unknown): string {
|
||||
const cost = asFiniteNumberOrNull(value);
|
||||
return cost === null ? 'cost unavailable' : `$${cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function ChatPage(): ReactElement {
|
||||
const { state, actions } = useChatConnection();
|
||||
const harness = useHarnessSelection();
|
||||
const hasConversation = state.conversationId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] min-h-0 flex-col overflow-hidden md:h-screen">
|
||||
<header className="border-b px-4 py-3">
|
||||
<h1 className="text-lg font-semibold">Chat</h1>
|
||||
</header>
|
||||
|
||||
{state.systemReload ? (
|
||||
<div role="status" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.systemReload.message)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.error ? (
|
||||
<div role="alert" className="border-b px-4 py-2 text-sm">
|
||||
{asString(state.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.ack ? (
|
||||
<div role="status" className="border-b px-4 py-1 text-xs opacity-70">
|
||||
Message accepted · conversation {asString(state.ack.conversationId, 'unknown')} · id{' '}
|
||||
{asString(state.ack.messageId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionPanel sessionInfo={state.sessionInfo} onSetThinking={actions.setThinking} />
|
||||
|
||||
<MessageTranscript messages={state.messages} streaming={state.streaming} text={state.text} />
|
||||
|
||||
{state.thinking ? (
|
||||
<section aria-label="Thinking" className="px-4 pb-2 text-xs italic opacity-80">
|
||||
{state.thinking}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ToolCallList tools={state.tools} />
|
||||
|
||||
{state.usage ? (
|
||||
<div aria-label="Usage" className="px-4 pb-2 text-xs opacity-80">
|
||||
{formatTokens(state.usage.tokens?.total)} · {formatCost(state.usage.cost)} ·{' '}
|
||||
{asString(state.usage.provider, 'unknown')}/{asString(state.usage.modelId, 'unknown')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CommandsPanel
|
||||
manifest={state.manifest}
|
||||
results={state.commandResults}
|
||||
approval={state.approval}
|
||||
pendingApproval={state.pendingApproval}
|
||||
hasConversation={hasConversation}
|
||||
onExecute={actions.executeCommand}
|
||||
onApprove={actions.approveCommand}
|
||||
onRunApproved={actions.runApprovedCommand}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
onSend={actions.sendMessage}
|
||||
onStop={actions.abort}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
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) => [
|
||||
@@ -55,4 +59,39 @@ describe('SPA route table', () => {
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the real chat page instead of the P1 placeholder at /chat, inside the authenticated group', () => {
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(authPaths).toContain('/chat');
|
||||
|
||||
const element = findRoute(routes, '/chat')?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error('Missing route element for /chat');
|
||||
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,71 @@
|
||||
# #1019 — Zero-timeout queue-guard harness race
|
||||
|
||||
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||
|
||||
## Objective
|
||||
|
||||
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||
2. Every case that intends status classification positively proves provider observation.
|
||||
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||
|
||||
## Review remediation — semantic timeout vs. liveness bound
|
||||
|
||||
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||
|
||||
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||
|
||||
Remediation:
|
||||
|
||||
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||
|
||||
Post-review evidence:
|
||||
|
||||
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||
|
||||
## 60% context hold
|
||||
|
||||
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||
@@ -0,0 +1,93 @@
|
||||
# W-B — Measure Pi's real tool registry
|
||||
|
||||
- **Task / internal ref:** W-B from the lease-remediation orchestrator brief (no matching `docs/TASKS.md` row; workers do not modify that file)
|
||||
- **Objective:** identify the exact tool names emitted as `event.toolName` by the installed Pi runtime and compare them with the broker's Pi read-only carve-out.
|
||||
- **Scope:** measurement and report only; no broker or runtime source changes. W-C is out of scope.
|
||||
- **Budget:** no explicit token cap; constrained to this scratchpad and one local commit.
|
||||
- **Installed runtime:** `@earendil-works/pi-coding-agent` / `pi` `0.84.1`.
|
||||
|
||||
## Method
|
||||
|
||||
I created a throwaway extension at `/tmp/measure-pi-tool-registry.ts` (not in the worktree). On `session_start` it recorded `pi.getAllTools()` and `pi.getActiveTools()`; on every `tool_call` it appended the exact `event.toolName`. I then launched an isolated, ephemeral Pi session with all built-ins explicitly selected:
|
||||
|
||||
```text
|
||||
PI_OFFLINE=1 pi --mode print --no-session --no-approve \
|
||||
--no-context-files --no-skills --no-prompt-templates --no-extensions \
|
||||
-e /tmp/measure-pi-tool-registry.ts \
|
||||
--tools read,bash,edit,write,grep,find,ls <deterministic probe prompt>
|
||||
```
|
||||
|
||||
The prompt exercised file read, content search, file search, directory listing, shell execution, file write, and file edit. Pi exited `0`; every selected tool produced one `tool_call`. The write/edit control artifact ended with exact content `after`, proving the mutating calls executed in order.
|
||||
|
||||
This runtime observation was cross-checked against the installed distribution's canonical registry at `dist/core/tools/index.js:17`, which declares the same seven names. The gate consumes the measured field directly at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:368`.
|
||||
|
||||
## Exact distinct built-in set
|
||||
|
||||
The installed Pi built-in registry is exactly:
|
||||
|
||||
```text
|
||||
{bash, edit, find, grep, ls, read, write}
|
||||
```
|
||||
|
||||
| Tool | Runtime registry observation | `tool_call` observation | Installed definition |
|
||||
| --- | --- | --- | --- |
|
||||
| `read` | `<builtin:read>` | observed once | `dist/core/tools/read.js:138` |
|
||||
| `bash` | `<builtin:bash>` | observed once | `dist/core/tools/bash.js:231` |
|
||||
| `edit` | `<builtin:edit>` | observed once | `dist/core/tools/edit.js:170` |
|
||||
| `write` | `<builtin:write>` | observed once | `dist/core/tools/write.js:138` |
|
||||
| `grep` | `<builtin:grep>` | observed once | `dist/core/tools/grep.js:79` |
|
||||
| `find` | `<builtin:find>` | observed once | `dist/core/tools/find.js:79` |
|
||||
| `ls` | `<builtin:ls>` | observed once | `dist/core/tools/ls.js:61` |
|
||||
|
||||
The raw distinct `event.toolName` result was:
|
||||
|
||||
```json
|
||||
["bash", "edit", "find", "grep", "ls", "read", "write"]
|
||||
```
|
||||
|
||||
Pi registers all seven, but its default active set is only `read`, `bash`, `edit`, and `write` (`dist/core/sdk.js:132`). The probe explicitly activated all seven so the three search/list tools could be observed at the hook.
|
||||
|
||||
## Positive control
|
||||
|
||||
The known `read` tool was the control. The method surfaced it twice:
|
||||
|
||||
1. `pi.getAllTools()` returned `read` with source path `<builtin:read>`.
|
||||
2. Reading `/tmp/pi-registry-probe/seed.txt`, which contained `CONTROL_TOKEN`, produced one hook record with `event.toolName === "read"`.
|
||||
|
||||
The control was therefore positive; the seven-name result is measured, not an empty-probe inference.
|
||||
|
||||
## Carve-out comparison and collision result
|
||||
|
||||
The broker currently declares `{"read", "grep", "find", "ls"}` at `packages/mosaic/framework/tools/lease-broker/daemon.py:54`.
|
||||
|
||||
- `read`: real built-in.
|
||||
- `grep`: real built-in.
|
||||
- `find`: real built-in.
|
||||
- `ls`: real built-in.
|
||||
|
||||
All four carve-out names are exact, case-sensitive Pi tool names.
|
||||
|
||||
The general execution/writing tool names are `bash`, `edit`, and `write`. Their intersection with the carve-out is empty:
|
||||
|
||||
```text
|
||||
{bash, edit, write} ∩ {read, grep, find, ls} = ∅
|
||||
```
|
||||
|
||||
Therefore no general shell-exec or file-mutating Pi tool shares a name with a carve-out entry. `grep` and `find` may invoke constrained search helpers internally, but neither exposes an arbitrary command interface; the arbitrary command tool is distinctly named `bash`.
|
||||
|
||||
The Mosaic extension separately registers the non-built-in custom tool `mosaic_context_recover` at `packages/mosaic/framework/runtime/pi/mosaic-extension.ts:379`; the broker handles that identity through its dedicated recovery exemption rather than the read-only set (`daemon.py:722`). Unknown or third-party custom tools are not part of Pi's built-in seven-name registry and remain outside the carve-out.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- `pi --version` → `0.84.1`.
|
||||
- Isolated probe exit → `0`.
|
||||
- Runtime `getAllTools()` count → `7`, all with `sourceInfo.source === "builtin"`.
|
||||
- Distinct hook names → `bash`, `edit`, `find`, `grep`, `ls`, `read`, `write`.
|
||||
- Hook counts → exactly one call for each of the seven names.
|
||||
- Mutation artifact after `write` then `edit` → exact content `after`.
|
||||
- Installed registry source → `allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"])`.
|
||||
|
||||
## Risks / limitations
|
||||
|
||||
- The probe deliberately disabled all other extensions, so extension-defined third-party tools were excluded from the built-in registry measurement. The production gate still receives those names and treats names outside the broker carve-out as mutating/fail-closed.
|
||||
- Explicit `--tools` activation was required to exercise `grep`, `find`, and `ls`; this does not imply they are active in Pi's default four-tool configuration.
|
||||
@@ -0,0 +1,99 @@
|
||||
# PR merge squash message field
|
||||
|
||||
- **Charter:** `/home/hermes/agent-work/CHARTER-PRMERGE-MESSAGE-FIELD.md`
|
||||
- **Owner:** `be-coder-08`
|
||||
- **Branch:** `fix/pr-merge-message-field`
|
||||
- **Base:** remote `main` / local `origin/main` at `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`
|
||||
- **Estate:** HOMELAB tooling shared by HOMELAB and USC
|
||||
|
||||
## Objective
|
||||
|
||||
Add an optional, identity-checked Gitea squash message to `pr-merge.sh` so genuine multi-author PRs retain non-poster branch authors without weakening hardcoded squash behavior.
|
||||
|
||||
## Binding requirements
|
||||
|
||||
1. `Do` remains hardcoded to `squash`; no provider/repository default may select merge style.
|
||||
2. A verified trailer uses a PR commit's linked `author.login` and that same commit's author email. No `/users/{login}` primary-email lookup occurs. Recorded rationale: this asks only what the provider can answer.
|
||||
3. A commit with `author.login` null blocks before merge, prints both the null provider fact and commit email fact, and names the escalation principal.
|
||||
4. The BLOCK arm must be observed firing; a normal canonical single-author API payload remains explicit squash plus its reviewed `head_commit_id`.
|
||||
5. Every provider mutation is read back from the provider; no real PR is merged during tests.
|
||||
|
||||
## Derived interface decisions
|
||||
|
||||
- Add `--co-author-trailers` rather than accepting arbitrary message text. The wrapper enumerates PR commits and constructs trailers, making an unchecked `Co-authored-by` line unexpressible.
|
||||
- Require `--escalate-to PRINCIPAL` with `--co-author-trailers`, so the BLOCK diagnostic always names a principal rather than a generic role.
|
||||
- Do not expose `MergeTitleField` separately. When trailers exist, set it from the provider PR title and set `MergeMessageField` only to construction-generated trailers. This preserves one provider source for the title and avoids an unrelated caller-controlled degree of freedom.
|
||||
- Preserve first-commit order and emit one trailer per distinct non-poster `author.login`, using that first linked commit's own email.
|
||||
|
||||
## Canonical delivery plan
|
||||
|
||||
1. Port the capability into the installed source of truth, `packages/mosaic/framework/tools/git/pr-merge.sh`; do not retain `infra/fleet/tools/git` as a second copy.
|
||||
2. Preserve canonical `--expect-head`, exact head branch/repository/SHA queue inspection, Gitea atomic head pinning, GitHub `--match-head-commit`, and delete-after-merge semantics.
|
||||
3. Do not port the deployed-only `--skip-queue-guard` bypass. Add the focused harness to the canonical framework-shell suite and re-establish RED/GREEN on the packaged baseline.
|
||||
4. Deliver through a reviewed package release followed by `mosaic update` with its default framework reseed. The installer snapshots, manifest-syncs framework-owned `tools/**`, and rolls back on failure.
|
||||
5. Before either estate relies on the change, require installed/package hash equality, `MergeMessageField` presence, and a green focused harness. Release/reseed ownership is currently unassigned and blocks activation after source merge.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED against the byte-identical deployed baseline (`sha256 08a65e8584c5…`): rc 1 with eight named failures. The wrapper rejected `--co-author-trailers`; the null-login path emitted none of the required BLOCK facts/principal; and both verified/ordinary API paths failed the stdin-config credential assertion (ordinary path exposed the fixture token through curl argv). Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-red.log`.
|
||||
- GREEN on the deployed-baseline candidate: verified linked multi-author payload, null-login BLOCK, required named principal, explicit squash, stdin-config token transport, and absence of `/users` lookup all passed. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-message-field-green.log`.
|
||||
- RED against canonical packaged baseline `c581ef48…`: rc 1 with 32 assertions. It rejects the new option, and the first harness version did not satisfy canonical head branch/repository/SHA metadata. Log: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-packaged-baseline-red.log`. The port adapts the fixture rather than weakening canonical head controls.
|
||||
- Provider capability probe against `git.mosaicstack.dev`: authenticated `be-coder-08` POST to deliberately nonexistent PR `2147483647` with both message fields returned JSON HTTP 404; the unauthenticated same request returned JSON HTTP 401 (not the charter's predicted 403). The authenticated-vs-unauthenticated differential proves write authorization resolved while no mergeable subject existed. `tl-mosaic` ruled the literal non-load-bearing: preserve the observed 404/401 pair and do not manufacture a 403 case. No cause was inferred and no real PR was targeted.
|
||||
- Provider-generated trailer behavior is not treated as exclusive or absent. The wrapper's VERIFIED/BLOCK decision binds each requested non-poster trailer to commit `author.login` plus that commit's email; it does not assume `MergeMessageField` is the squash's only trailer source. The poster is omitted from the constructed list because the resulting squash author already records the poster; any additional provider-generated trailer is outside this change's unmeasured mechanism.
|
||||
- An early candidate SHA-256 `5de32876990e4f26920448cb3220cc7f1146d558b4dd2bc1ee1a2abee2f2cbe6` passed the initial harness, then author-side review found credential-fallback and argv-exposure defects. The live deployed wrapper was atomically restored to baseline SHA-256 `08a65e8584c52c6d41ea1c686f8b95585c21e4b37320a2447eba09359a0e02c1`; the remediated candidate remains only in the worktree.
|
||||
|
||||
## Remediation and current review state
|
||||
|
||||
1. Token and Basic Auth now use stdin curl configuration, not argv. PR title, contributor email, and the JSON payload also remain out of child argv.
|
||||
2. Each credential attempt binds commit inspection and merge. A token failure during either inspection or mutation causes Basic fallback to repeat inspection before mutation; the payload pins the inspected `head_commit_id`.
|
||||
3. Focused tests cover token-resolution fail-closed behavior, both HTTP-401 fallback seams, metadata/credential argv absence, null-login BLOCK, explicit squash, canonical reviewed-head binding, unchanged ordinary payload, and retained log-safe provider diagnostics. Token-resolution RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-token-resolution-red.log`.
|
||||
4. Codex review rounds 3–5 requested retained provider error text, log-safe provider diagnostics, fail-closed credential fallback, stable value-option parsing, and PR-title trailer-injection prevention. These are remediated with regression assertions. A post-remediation independent review is still required.
|
||||
5. **Accepted linkage limitation:** `author.login` resolution proves that the commit address maps to a registered provider account. It does not prove that the named principal authored the commit because Git author metadata is self-asserted. This gate checks attribution linkage, not authorship; commit signing is out of scope and currently unadopted. Coordinators explicitly ruled that this does not add a third state.
|
||||
6. Codex's sandbox could not execute the harness because its checkout was read-only; that environmental limitation is recorded separately from host-side test results.
|
||||
|
||||
## Disposable provider fixture acceptance
|
||||
|
||||
- Use a retained scratch repository only, with two branch authors and `author != committer` on at least one commit.
|
||||
- Arm A supplies a message-field trailer for one non-poster; record whether that value lands without forcing the partial-pair result into under-specified `APPENDS`/`REPLACES` labels. Demonstrate an absence control.
|
||||
- Arm B includes a registered trailer for a different non-poster on a branch commit; record whether it survives or drops. Verify identity through an existing commit whose `author.login` resolves and demonstrate an absence control.
|
||||
- Parse landed trailers key-agnostically with `^[A-Za-z-]+-[Bb]y:` and record generated poster pair presence/absence plus resulting poster attribution.
|
||||
- Record `/users/<login>` status and raw email only as non-gating estate telemetry. Never read `active`, `visibility`, or any profile field as an identity gate.
|
||||
- Use distinct principals: poster `be-coder-08`, merger `Mos`, Arm A `be-coder-07`, and Arm B `be-coder-06`. Capture every trailer-shaped line verbatim and in order. Zero trailer lines means the generator did not fire and the run is `VOID`, not evidence that either arm dropped.
|
||||
- Report the same read-back evidence to `mos-claude` on socket `default` and `tl-mosaic` on socket `mosaic-fleet`. Report values rather than mechanism inferences and stop on any poster-attribution regression.
|
||||
|
||||
## Fixture preflight
|
||||
|
||||
- Retained public repository: `mosaicstack/prmerge-trailer-fixture`; PR `#1`, posted by `be-coder-08` and reserved for merge by `Mos`.
|
||||
- Existing `mosaicstack/stack` commits resolve `be-coder-07` and `be-coder-06` through `author.login`; exact addresses are `[email protected]` and `[email protected]`.
|
||||
- Non-gating HOMELAB telemetry for authenticated reader `be-coder-08`: `/api/v1/users/be-coder-06` returned HTTP 200 with raw `email` value `[email protected]`.
|
||||
- Provider preflight showed PR commit enumeration is newest-first. A new RED test proved that deriving `head_commit_id` from the final array element selected the wrong commit. The candidate now reads `.head.sha` from the authenticated PR endpoint before enumeration, verifies it appears in the commit set, and atomically pins that SHA in the explicit squash payload. RED: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-head-order-red.log`.
|
||||
- Fixture PR head `f6ba6e5105031fa21f5ff7bd8e4379d99c16e1de` has `author.login=be-coder-07`, `committer.login=be-coder-08`, and branch-message trailer `Co-authored-by: be-coder-06 <[email protected]>`.
|
||||
|
||||
## Fixture result
|
||||
|
||||
- `Mos` merged retained fixture PR `#1` through staged candidate SHA-256 `60e779a85fd13b729d859ea7c986d1e9b1641b97991611329226c1b3113ffb6e`; resulting squash commit: `3f550715d9bc716426fd355a65fe997b3a90fa7d` with one parent.
|
||||
- Provider read-back: poster/commit author `be-coder-08`, committer/merger `Mos`. The run is non-void.
|
||||
- Trailer-shaped lines, verbatim and in order:
|
||||
1. `Co-authored-by: be-coder-07 <[email protected]>`
|
||||
2. `Co-authored-by: be-coder-08 <[email protected]>`
|
||||
- Arm A supplied field value (`be-coder-07`) landed. Arm B branch trailer (`be-coder-06`) dropped. Both fabricated absence controls remained absent. No `Co-committed-by:` line landed.
|
||||
- The candidate payload construction explicitly excludes the poster and supplied only the Arm A `be-coder-07` line. Therefore the landed poster line was provider-generated, not candidate-composed. The raw result supports `FIELD LANDS`, `BRANCH DROPS`, and `POSTER GENERATED`; it does not support a claim that candidate code supplied the poster. Evidence: `/home/hermes/agent-work/be-coder-08/evidence/prmerge-fixture-readback.log` and the retained provider object.
|
||||
- Retained fixture PR `#2` measured the N=2 shape needed by `#1030`: supplied `be-coder-07` then `be-coder-06`; both landed in that order, followed by the provider-generated poster line. No truncation or dedup occurred at N=2. Resulting squash: `39db9d13aed0…`.
|
||||
|
||||
## Current hold point
|
||||
|
||||
PR `mosaicstack/stack#1066` is open. Its first frozen head `f4b162fa…` was terminal-green in Woodpecker `mosaic` pipeline `#2225`, but that evidence becomes stale when the canonical port moves the head. The deployed wrapper remains baseline `08a65e85…`; no manual copy will occur. Canonical port tests, commit amendment, rebase, one guarded force-with-lease, exact-head CI, and new independent review remain. Even after source merge, activation remains blocked on an assigned package-release/reseed owner and installed-byte read-back.
|
||||
|
||||
## Security review 96 remediation
|
||||
|
||||
Exact reviewed predecessor head: `1ceb11058f64dd7f4a817ceb2124f980a1c4dd23`.
|
||||
|
||||
RED-first focused harness produced 10 named failures: all curl calls lacked size/time/connect bounds; raw ESC email reached mutation; oversized and stalled curl failures were discarded and reached mutation; nonempty Basic output with resolver rc 91 authorized mutation.
|
||||
|
||||
Security remediation:
|
||||
|
||||
- Removed the cross-principal HTTP-401 Basic fallback. Both inspection-401 and merge-401 paths now refuse without Basic resolution or mutation; `get_gitea_basic_auth` references in the merge subject are 0.
|
||||
- Applied `--max-filesize`, `--max-time`, and `--connect-timeout` to all 3/3 provider curl sites and fail closed on curl transport rc at all 3/3 sites.
|
||||
- Required linked email bytes to be ASCII and printable before constructing `MergeMessageField`; guarded construction sites 1/1.
|
||||
|
||||
GREEN: message-field, exact-head, empty-UID/API, queue branch/repository/SHA, bash syntax, ShellCheck, and diff check pass. R7 total-removal mutants went RED: email guard 3 rows; bound switches 1 row; transport-rc guards 4 rows; HTTP-401 refusal 3 rows. R7 bound: mutants prove total removal only; explicit denominators above prove site coverage.
|
||||
@@ -0,0 +1,99 @@
|
||||
# P3-R1 — Routing Health Enum + `/mcp` Wiring Scratchpad
|
||||
|
||||
**Task:** P3 hands-on acceptance blockers #1 and #5
|
||||
**Mission:** `mvp-20260312` (active)
|
||||
**Branch:** `feat/webui-p3r1-routing-mcp` from `origin/next`
|
||||
**Required base:** `20718b5a273d243363a4f5cbef5bbf692a805bdb`
|
||||
**Tracking ref:** Direct P3-R1 author brief; no provider issue supplied; no PR or merge authorized
|
||||
**Started:** 2026-08-11T14:52:52-05:00
|
||||
|
||||
## Objective
|
||||
|
||||
Fix exactly two P3 acceptance blockers:
|
||||
|
||||
1. Make routing consume the canonical `ProviderHealthStatus` enum, with `healthy` and `degraded` routable and `down` non-routable, at both routing decision sites.
|
||||
2. Wire `McpClientModule` into `CommandsModule`, make `McpClientService` required, remove the unreachable unavailable-service branch, and prove `/mcp status` reaches the client.
|
||||
|
||||
Explicitly excluded: provider registry/adapters, `provider.service.ts`, `agent.service.ts`, `chat.gateway.ts`, fallback membership, task classification, selector UI, conversation resume, reload UX, WS/origin/handshake behavior, dependencies/lockfile, and `apps/web/**` changes.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Confirm exact base/branch and inspect every cited source/test anchor plus all direct `CommandExecutorService` construction sites.
|
||||
2. Record baseline gateway and focused routing/commands/MCP test totals.
|
||||
3. Add regression tests first and run focused tests to capture expected RED failures.
|
||||
4. Apply only the typed routing helper/signature changes and required MCP module/constructor/guard changes; update impossible `up` fixtures.
|
||||
5. Run focused tests, all user-required verification gates, lockfile/scope/diff checks, and record counts.
|
||||
6. Obtain independent spec and code/security review; remediate any findings and repeat affected gates.
|
||||
7. Commit conventionally, run the pre-push queue guard, and push only the feature branch (no PR or merge).
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working soft cap: **30K tokens**, based on two bounded gateway bug fixes, focused TDD, full gateway/root verification, independent review, and branch delivery. One coding worker will execute serially; reviews will be independent and serial to avoid worktree collisions.
|
||||
|
||||
## Startup Evidence
|
||||
|
||||
- `git fetch origin` rc=0.
|
||||
- `origin/next` confirmed exactly `20718b5a273d243363a4f5cbef5bbf692a805bdb`.
|
||||
- Local and remote `feat/webui-p3r1-routing-mcp` were absent before creation.
|
||||
- Branch creation rc=0; HEAD equals the required base.
|
||||
- Harness-owned `.mosaic/orchestrator/session.lock` was already dirty and remains excluded from staging/commit.
|
||||
|
||||
## Baseline Evidence
|
||||
|
||||
- Gateway full suite: rc=0; **64 files / 693 tests passed**, 7 files / 17 tests skipped (71 files / 710 tests total).
|
||||
- Routing sub-suite (`src/agent/routing`): rc=0; **3 files / 105 tests passed**.
|
||||
- Commands/MCP sub-suite (`src/commands`, `src/mcp-client`): rc=0; **6 files / 76 tests passed**.
|
||||
|
||||
## TDD and Implementation Evidence
|
||||
|
||||
- Routing RED: after canonical fixture/test changes but before the service fix, focused routing run returned rc=1 with **15 failed / 91 passed (106)** because the old `up`/`ok` gates rejected `healthy` and `degraded`.
|
||||
- Routing GREEN: canonical `ProviderHealthStatus` map types, one `isRoutable` helper, and both comparison sites corrected; focused routing suite passed.
|
||||
- MCP behavior test now drives `/mcp status` through a required mock client and asserts the client is called, success is returned for zero servers, and the former unavailable message is absent.
|
||||
- MCP wiring mutation RED used the final `Reflect.getMetadata('imports', CommandsModule)` assertion with only the production `McpClientModule` import/registration temporarily removed: rc=1, exact failure `expected [GCModule, …] to include McpClientModule`.
|
||||
- MCP wiring GREEN after byte-for-byte production restoration: rc=0. Temporary mutation did not remain.
|
||||
- Every direct `new CommandExecutorService(...)` test construction now supplies a non-null MCP client mock.
|
||||
- Initial Codex worker launch failed rc=1 from missing OpenAI bearer authentication. First Mosaic Claude launch failed rc=1 because Distrobox-local runtime contracts were absent; retry with the supported host `MOSAIC_HOME=/home/jwoltje/.config/mosaic` succeeded.
|
||||
|
||||
## Review Evidence
|
||||
|
||||
- Independent spec review: **approve**, 0 blockers, scope OK.
|
||||
- Independent code/security review: **approve**, 0 blockers, 0 critical/high security findings. It suggested an actual module-wiring assertion, which was added with valid mutation RED/GREEN evidence.
|
||||
- Independent final re-review after remediation: **approve**, 0 blockers, 0 critical/high security findings, no remaining findings.
|
||||
- Optional missing-provider/`undefined` test suggestion was not adopted: the brief explicitly requires the three canonical statuses (`healthy`, `degraded`, `down`) and forbids scope expansion; runtime behavior for absent keys remains `undefined` → non-routable through the required helper signature.
|
||||
|
||||
## Documentation Assessment
|
||||
|
||||
- `docs/PRD.md` already requires provider fallback/routing and MCP capability; this increment restores implementation to those existing contracts.
|
||||
- No public API endpoint, payload schema, auth/permission rule, navigation, deployment procedure, or new user workflow changes. OpenAPI, endpoint index, user/admin/developer guides, and sitemap are therefore N/A for this bounded repair.
|
||||
- This append-only scratchpad is the implementation, TDD, review, and verification record. Canonical docs remain in-repo; no publishing action is in scope.
|
||||
|
||||
## Final Verification Evidence
|
||||
|
||||
All required and repository-situational gates completed with rc=0:
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `pnpm install --frozen-lockfile` | rc=0 |
|
||||
| Gateway typecheck | rc=0 |
|
||||
| Gateway lint | rc=0 |
|
||||
| Routing focused suite | rc=0; 3 files / 106 tests |
|
||||
| Commands/MCP focused suite | rc=0; 6 files / 78 tests |
|
||||
| Gateway full suite | rc=0; 64 files / 696 tests passed; 7 files / 17 tests skipped |
|
||||
| Gateway build | rc=0 |
|
||||
| Root typecheck | rc=0; 45/45 tasks |
|
||||
| Web test | rc=0; 19 files / 154 tests |
|
||||
| Root lint | rc=0; 25/25 tasks |
|
||||
| Root format check | rc=0 |
|
||||
| `git diff --check` | rc=0 |
|
||||
|
||||
- Gateway suite before→after: **693→696 passing tests**; skipped remained 17 (total 710→713).
|
||||
- Routing focused before→after: **105→106 passing tests**.
|
||||
- Commands/MCP focused before→after: **76→78 passing tests**.
|
||||
- `pnpm-lock.yaml` SHA-256 before/after frozen install: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; diff versus `origin/next` rc=0.
|
||||
- Verified no changed path under `apps/web/**`, provider service/adapters, `agent.service.ts`, `chat.gateway.ts`, or lockfile.
|
||||
- Verified both `McpClientModule` production wiring lines remain and no impossible `up`/`ok` routing status checks/fixtures remain.
|
||||
- Verified code/test diff SHA-256: `27b41a855084b9dd85a7bc2a79fa3251d114ee226a4f1b835e65134c25a4f5a8`.
|
||||
|
||||
## Delivery State
|
||||
|
||||
Implementation, testing, documentation assessment, and independent review are complete. Remaining authorized actions: format this final scratchpad append, create one conventional commit, run the required push queue guard, and push only `feat/webui-p3r1-routing-mcp`; no PR or merge.
|
||||
@@ -0,0 +1,271 @@
|
||||
# WebUI Phase P — P4-1 Projects + Tasks SPA Scratchpad
|
||||
|
||||
**Task ID:** P4-1
|
||||
**Tracking ref:** Phase P RFC §6.4 / §2.4 author brief; no provider issue supplied
|
||||
**Mission context:** `mvp-20260312` (active)
|
||||
**Branch:** `feat/webui-p4-1` from `origin/next`
|
||||
**Started:** 2026-08-11
|
||||
**Role:** orchestrator-controlled author worker; `docs/TASKS.md` remains orchestrator-only and is not part of this increment
|
||||
|
||||
## Original tasking
|
||||
|
||||
Implement the bounded P4-1 SPA parity slice exactly as briefed: real authenticated `/projects`, `/projects/:id`, and `/tasks` React Router pages ported from the existing Next baseline; reuse existing project/task components; preserve relative same-origin REST access; support only the existing project/task PATCH edit flows; add route error boundaries and page tests; pin Vite strict port 3100 and legacy Next dev/start to 3101; make no gateway, package, dependency, nav-shell, chat, settings, or admin changes; verify, commit, and push only `feat/webui-p4-1` (no PR or merge). If any required REST endpoint is absent, stop as `BLOCKED:` rather than inventing a workaround.
|
||||
|
||||
## Objective and acceptance map
|
||||
|
||||
- A: `/projects` list with loading, cards, empty, and surfaced API-error states; defer MissionStatus side panel.
|
||||
- B: `/projects/:id` detail with overview/tasks/missions tabs, parallel project/mission/task load, bounded project and task PATCH edits.
|
||||
- C: `/tasks` list/kanban with bounded task PATCH edits.
|
||||
- D: replace the three route placeholders and add route error boundaries without changing chat.
|
||||
- E: use only `@/lib/types` and `@/lib/api`; relative `/api/...` REST paths only.
|
||||
- F: Vite `strictPort: true`; Next dev/start pinned to 3101; no proxy/dependency changes.
|
||||
- G: fixture-backed Vitest coverage for lists, states, tabs/toggles, PATCH calls, and real route elements; all named verification gates pass.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Verify the required gateway REST routes and inspect the exact `origin/next` SPA, Next baseline pages, shared components, types, API helper, and P3 test conventions.
|
||||
2. Record the pre-change web test count and add required page/route tests first, observing expected RED failures.
|
||||
3. Port the three pages and shared route error boundary, then wire routes and bounded PATCH flows.
|
||||
4. Apply only the two dev-port pin changes.
|
||||
5. Run focused tests, all user-required gates, lockfile and same-origin checks, and `git diff --check`.
|
||||
6. Obtain independent spec/code/security review; remediate and repeat affected gates until clear.
|
||||
7. Commit conventionally, run the required pre-push queue guard, push only the feature branch, and record exact evidence here.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
TDD is applied because this adds user-visible data/edit behavior. Component tests mock only the existing API boundary and exercise rendered behavior and PATCH payloads. Primary situational evidence is the required page interaction suite plus route-resolution checks; baseline evidence is typecheck, lint, full web test, build, frozen install, formatting/diff hygiene, and same-origin grep.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap was supplied. Working soft cap: **50K tokens**, derived from three coupled React pages, route/error wiring, interaction tests, port config, review/remediation, and full verification. One Codex implementation worker and independent review workers will be used serially to avoid worktree collisions.
|
||||
|
||||
## Base evidence
|
||||
|
||||
- `git fetch origin` rc=0.
|
||||
- `origin/next` and branch start: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||
- Branch created: `feat/webui-p4-1` tracking `origin/next`.
|
||||
- Harness-owned `.mosaic/orchestrator/session.lock` is dirty and must remain unstaged/uncommitted.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- [x] Loaded active mission manifest, latest scratchpad, top-level tasks, PRD, orchestration/delivery/frontend/QA/documentation/review/TypeScript guides, and matching implementation skills.
|
||||
- [x] Confirmed exact base SHA and created the feature branch.
|
||||
- [x] Required REST endpoints verified in Gateway source: project list/detail/PATCH, task list/filter/detail/PATCH, and mission list all exist.
|
||||
- [x] Scope assumption check found a blocking contradiction before source implementation.
|
||||
- [ ] Pre-change web test count recorded.
|
||||
- [ ] RED tests observed.
|
||||
- [ ] Implementation complete.
|
||||
- [ ] Independent review clear.
|
||||
- [x] Required verification gates run against the unchanged web baseline; exact leak-grep expectation is independently blocked by 12 pre-existing matches.
|
||||
- [x] Blocker record committed as `5ede86a5` and feature branch pushed; no PR opened and no merge performed.
|
||||
|
||||
## Blocker — 2026-08-11
|
||||
|
||||
`P4-1` is blocked because the bounded edit UX asserted by the brief does not exist at the confirmed `origin/next` base (`e00cc475`):
|
||||
|
||||
- `apps/web/src/components/tasks/task-detail-modal.tsx` is read-only. Its props are only `task` and `onClose`; it contains no input/select/textarea, update callback, or `api()`/PATCH call.
|
||||
- `apps/web/src/app/(dashboard)/projects/[id]/page.tsx` contains no project edit controls and no project/task PATCH call.
|
||||
- `apps/web/src/app/(dashboard)/tasks/page.tsx` does not open `TaskDetailModal`; its click handler only logs that a future detail view will be added.
|
||||
- A repository grep over those reference/component paths finds zero `method: 'PATCH'` calls and zero edit/save form controls.
|
||||
- The required leak grep also disproves the brief's baseline claim that `apps/web/src` is clean: the exact command returns rc=0 with 12 pre-existing matches at `origin/next` (external font links, an avatar placeholder, an SVG namespace, and test fixtures). There are zero new matches in the worktree, but making the exact grep empty would require unrelated changes the brief forbids.
|
||||
|
||||
The brief simultaneously requires tests proving task/project PATCH edits and forbids inventing edit UX, fields, or flows beyond what those references expose. Adding controls or callback contracts would therefore be new design outside the authorized port/wiring scope. Per the explicit stop rule, no SPA source, route, config, package, lockfile, or test implementation was started.
|
||||
|
||||
## Verification evidence on unchanged web baseline
|
||||
|
||||
These gates were still run to distinguish the brief contradiction from a broken base:
|
||||
|
||||
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
|
||||
- `pnpm --filter @mosaicstack/web lint` — rc=0.
|
||||
- `pnpm --filter @mosaicstack/web test` — rc=0; 15 files, 133 tests passed. Before→after remains 133→133 because implementation was halted.
|
||||
- `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated.
|
||||
- `pnpm install --frozen-lockfile` — rc=0; lock SHA-256 remained `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; `git diff` for the lockfile rc=0.
|
||||
- `git grep -nE "http://|https://" -- apps/web/src` — rc=0 with 12 pre-existing matches; `origin/next` count=12, current HEAD count=12, new worktree additions=0.
|
||||
- `pnpm format:check` — rc=0.
|
||||
- `git diff --check` — rc=0.
|
||||
|
||||
## Delivery evidence
|
||||
|
||||
- Initial queue-guard invocation through shell `~` failed rc=127 because this Distrobox resolves `HOME` to `/home/jwoltje/distrobox-homes/mosaic-dev`, where the injected fleet status already reports the tools installation missing.
|
||||
- Correct supported host-tool invocation `/home/jwoltje/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B feat/webui-p4-1` — rc=0; branch absent remotely and queue clear.
|
||||
- `git push -u origin feat/webui-p4-1` — rc=0. The push hook additionally ran repository preflight, typecheck (45/45 tasks), lint (25/25 tasks), and format check successfully.
|
||||
- No PR was opened and no merge was attempted, per the brief.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- The active harness mutates `.mosaic/orchestrator/session.lock`; it is excluded from staging.
|
||||
- The Phase P §6.4 prose is absent from this checkout, so the brief-named route spec and existing Next pages are the bounded implementation anchors.
|
||||
- The MissionStatus `/api/coord/status` panel is explicitly deferred to a follow-up and must not enter P4-1.
|
||||
|
||||
## REV 2 continuation — 2026-08-10T21:59:17-05:00
|
||||
|
||||
REV 2 supersedes the original tasking above. The independent check confirmed that the Next reference pages and `task-detail-modal.tsx` are read-only, so project/task editing is deliberately deferred to P4-1b. Do not re-litigate or implement the former PATCH requirements.
|
||||
|
||||
### Revised objective and acceptance map
|
||||
|
||||
- A: `/projects` read-only SPA list with loading, cards, empty, and surfaced API-error states; card navigation to `/projects/:id`; no MissionStatus panel.
|
||||
- B: `/projects/:id` read-only SPA detail using `useParams`, `useNavigate`, and the specified three-request `Promise.all`; preserve reference overview/tasks/missions tabs and read-only task modal.
|
||||
- C: `/tasks` read-only SPA list/kanban view with the existing read-only task modal.
|
||||
- D: replace only the three route placeholders and add page-local route error boundaries without modifying chat.
|
||||
- E: use only `@/lib/types` and `@/lib/api` with relative `/api/...` REST paths.
|
||||
- F: add Vite `strictPort: true` and pin legacy Next dev/start to 3101 without dependency or proxy changes.
|
||||
- G: add fixture-backed page tests and route-resolution assertions; run every user-specified verification gate and the narrowed new-code origin check.
|
||||
|
||||
### Revised plan
|
||||
|
||||
1. Reset `feat/webui-p4-1` to the exact `origin/next` base and independently verify the read-only reference/component/API assumptions.
|
||||
2. Record the pre-change web suite count; write and run focused page/route tests first to observe expected RED failures.
|
||||
3. Port the three read-only pages, add independent route error boundaries, wire routes, and apply only the two dev-port pins.
|
||||
4. Run focused tests, full required web gates, frozen install/lockfile proof, origin check, format/diff hygiene, and accessibility/state-transition sanity checks.
|
||||
5. Obtain independent spec/code/security review, remediate every blocker, and repeat affected gates.
|
||||
6. Commit conventionally, run the supported pre-push queue guard, and push only `feat/webui-p4-1`; no PR and no merge.
|
||||
|
||||
### Revised budget and session state
|
||||
|
||||
- No explicit token cap was supplied. Working soft cap remains **50K tokens**.
|
||||
- TDD is required by the user and frontend skill for the new SPA behavior; tests must fail for missing pages before production implementation.
|
||||
- Documentation assessment: this is a parity port of existing read-only behavior, not a new public workflow or API contract; the task scratchpad is the required delivery record, with no user/developer/API documentation changes in this bounded increment.
|
||||
- Exact base confirmed after fresh fetch/reset: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||
- Remote `feat/webui-p4-1` is absent; the eventual push is a fresh branch creation.
|
||||
- Harness-owned `.mosaic/orchestrator/session.lock` remains excluded from staging.
|
||||
|
||||
## REV 2 implementation pass — 2026-08-11T22:05:00Z
|
||||
|
||||
### Startup verification
|
||||
|
||||
- Loaded required startup files: `/home/jwoltje/.config/mosaic/CONSTITUTION.md`, `/home/jwoltje/.config/mosaic/SOUL.md`, project `AGENTS.md`, `/home/jwoltje/.config/mosaic/guides/E2E-DELIVERY.md`, `docs/PRD.md`, and this scratchpad.
|
||||
- Loaded required skills: `test-driven-development`, `vitest`, `vite`, `next-best-practices`, and `verification-before-completion`.
|
||||
- Loaded required runtime guide: `/home/jwoltje/.config/mosaic/runtime/codex/RUNTIME.md`.
|
||||
- Structured reasoning tool availability verified through the harness before planning.
|
||||
- `git rev-parse HEAD` confirmed exact required base: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`.
|
||||
- `git status --short` at startup showed only the expected untracked append-only scratchpad; `.mosaic/orchestrator/session.lock` did not appear and must remain unstaged if it changes later.
|
||||
|
||||
### Current plan
|
||||
|
||||
1. Record baseline evidence.
|
||||
2. Add focused failing SPA page/route specs first.
|
||||
3. Run focused RED command and record the missing-page failure.
|
||||
4. Implement the bounded read-only SPA pages, boundaries, route wiring, and the two dev-port pins.
|
||||
5. Run the required verification matrix and inspect diff hygiene.
|
||||
|
||||
### Pre-change baseline
|
||||
|
||||
- Command: `pnpm --filter @mosaicstack/web test`
|
||||
- rc=0
|
||||
- File/test totals before changes: `15 files / 133 tests`
|
||||
- Notes: baseline already includes `/chat` SPA route coverage and raw React `createRoot`/`act` page specs that this pass should mirror.
|
||||
|
||||
### RED evidence before production implementation
|
||||
|
||||
- Command: `pnpm --filter @mosaicstack/web test -- src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
|
||||
- rc=1
|
||||
- Expected missing-feature reason confirmed:
|
||||
- `src/spa/pages/projects.spec.tsx`, `src/spa/pages/project-detail.spec.tsx`, `src/spa/pages/tasks.spec.tsx`, and `src/spa/routes.spec.tsx` fail import resolution because the SPA page modules do not exist yet.
|
||||
- `src/spa/pages/resource-route-boundaries.spec.tsx` fails because the current `/projects`, `/projects/:id`, and `/tasks` routes still render placeholders without route-level alert fallbacks.
|
||||
|
||||
### Implementation summary
|
||||
|
||||
- Added SPA pages:
|
||||
- `apps/web/src/spa/pages/projects.tsx`
|
||||
- `apps/web/src/spa/pages/project-detail.tsx`
|
||||
- `apps/web/src/spa/pages/tasks.tsx`
|
||||
- Added page-local route boundary components in `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`.
|
||||
- Wired `/projects`, `/projects/:id`, and `/tasks` in `apps/web/src/routes.tsx` with real elements and `errorElement`s.
|
||||
- Added fixture-backed raw React Vitest coverage plus route assertions for the three pages and their boundaries.
|
||||
- Applied only the requested dev topology pins:
|
||||
- `apps/web/vite.config.ts`: `server.strictPort = true`
|
||||
- `apps/web/package.json`: `next dev -p 3101`, `next start -p 3101`
|
||||
|
||||
### Post-implementation verification
|
||||
|
||||
- Focused changed specs:
|
||||
- Command: `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx`
|
||||
- rc=0
|
||||
- Result: `5 files / 26 tests` passed.
|
||||
- `pnpm --filter @mosaicstack/web typecheck` — rc=0.
|
||||
- `pnpm --filter @mosaicstack/web lint` — rc=0.
|
||||
- `pnpm --filter @mosaicstack/web test` — rc=0; post-change totals `19 files / 153 tests`.
|
||||
- `pnpm --filter @mosaicstack/web build` — rc=1.
|
||||
- Limitation: Next/Turbopack hit a sandbox/runtime failure while processing `apps/web/src/app/globals.css`: `creating new process`, `binding to a port`, `Operation not permitted (os error 1)`. This appears environmental, not route-code-specific.
|
||||
- `pnpm --filter @mosaicstack/web build:vite` — rc=0.
|
||||
- `pnpm install --frozen-lockfile` — rc=1.
|
||||
- Limitation: repo `prepare` hook attempted to lock `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack/.git/config`, which is read-only in this harness.
|
||||
- `pnpm-lock.yaml` SHA-256 before/after install attempt: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
|
||||
- `origin/next` `pnpm-lock.yaml` SHA-256: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`.
|
||||
- `git diff -- pnpm-lock.yaml` — rc=0 (unchanged).
|
||||
- `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty, as required).
|
||||
- `pnpm format:check` — rc=0.
|
||||
- `git diff --check` — rc=0.
|
||||
|
||||
### Final worktree check
|
||||
|
||||
- `git status --short` shows only the authorized web files plus this scratchpad.
|
||||
- `.mosaic/orchestrator/session.lock` remains unstaged.
|
||||
- Current changed file set:
|
||||
- `apps/web/package.json`
|
||||
- `apps/web/src/routes.tsx`
|
||||
- `apps/web/src/spa/routes.spec.tsx`
|
||||
- `apps/web/vite.config.ts`
|
||||
- `apps/web/src/spa/pages/page-errors.ts`
|
||||
- `apps/web/src/spa/pages/page-fixtures.ts`
|
||||
- `apps/web/src/spa/pages/project-detail.spec.tsx`
|
||||
- `apps/web/src/spa/pages/project-detail.tsx`
|
||||
- `apps/web/src/spa/pages/projects.spec.tsx`
|
||||
- `apps/web/src/spa/pages/projects.tsx`
|
||||
- `apps/web/src/spa/pages/resource-route-boundaries.spec.tsx`
|
||||
- `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`
|
||||
- `apps/web/src/spa/pages/tasks.spec.tsx`
|
||||
- `apps/web/src/spa/pages/tasks.tsx`
|
||||
- `docs/scratchpads/webui-p4-1.md`
|
||||
|
||||
## P4-1 REV 2 Final Delivery Verification — 2026-08-10T22:23:00Z
|
||||
|
||||
### Spec/Security Review Verdicts (Independent Reviews)
|
||||
|
||||
Both independent reviews cleared P4-1 REV 2 implementation without blockers:
|
||||
- **Spec review:** approved; 0 blockers, 0 should-fix findings
|
||||
- **Code/security review:** approved; 0 blockers, 0 critical/high findings, 0 should-fix recommendations
|
||||
|
||||
### Final Fresh Gate Verification (This Session)
|
||||
|
||||
All verification gates executed sequentially with rc=0 (except where noted):
|
||||
|
||||
1. `pnpm --filter @mosaicstack/web typecheck` — rc=0
|
||||
2. `pnpm --filter @mosaicstack/web lint` — rc=0
|
||||
3. `pnpm --filter @mosaicstack/web test` — rc=0; **19 files / 153 tests** (baseline: 15 files / 133 tests; added: 4 files / 20 tests)
|
||||
4. `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated
|
||||
5. `pnpm --filter @mosaicstack/web build:vite` — rc=0; Vite production bundle generated
|
||||
6. Lockfile integrity:
|
||||
- Before install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
|
||||
- After frozen install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`
|
||||
- Equality: ✓ verified
|
||||
- `git diff --quiet origin/next -- pnpm-lock.yaml` rc=0 ✓
|
||||
7. `pnpm format:check` — rc=0; all matched files use Prettier code style
|
||||
8. `git diff --check` — rc=0; no trailing whitespace or merged conflict markers
|
||||
9. New-code origin check: `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty result, as required)
|
||||
10. `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` — rc=0; 5 files / 26 tests passed
|
||||
|
||||
### Scope Audit
|
||||
|
||||
**Authorized in-scope changes:**
|
||||
- ✓ `apps/web/package.json` — dev port pins only
|
||||
- ✓ `apps/web/vite.config.ts` — strictPort flag only
|
||||
- ✓ `apps/web/src/routes.tsx` — route wiring with errorElements
|
||||
- ✓ `apps/web/src/spa/pages/projects.tsx` — read-only SPA list
|
||||
- ✓ `apps/web/src/spa/pages/project-detail.tsx` — read-only SPA detail
|
||||
- ✓ `apps/web/src/spa/pages/tasks.tsx` — read-only SPA list/kanban
|
||||
- ✓ `apps/web/src/spa/pages/resource-route-error-boundaries.tsx` — page-local error components
|
||||
- ✓ `apps/web/src/spa/pages/*.spec.tsx` — fixture-backed tests (4 new)
|
||||
- ✓ `docs/scratchpads/webui-p4-1.md` — append-only record
|
||||
|
||||
**Out-of-scope verification:**
|
||||
- `.mosaic/orchestrator/session.lock` — not modified/staged ✓
|
||||
- `pnpm-lock.yaml` — not modified ✓
|
||||
- `apps/gateway/**` — not modified ✓
|
||||
- `packages/**` — not modified ✓
|
||||
- Chat SPA — not modified ✓
|
||||
- Settings, admin, navigation, or auth flow — not modified ✓
|
||||
|
||||
### Next Action
|
||||
|
||||
Commit, run authorized queue guard, and push only `feat/webui-p4-1` (no PR, no merge).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user