Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d84e4ee03 | ||
|
|
4aaf41dd1a | ||
|
|
d46a2d675a | ||
|
|
48bb19310d | ||
|
|
caebf9ef70 | ||
|
|
b2e005f2b4 |
@@ -1,624 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import * as nodeOs from 'node:os';
|
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
||||||
import * as nodeUrl from 'node:url';
|
|
||||||
import { MODULE_METADATA } from '@nestjs/common/constants.js';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
interface ComposedModuleGraph {
|
|
||||||
imports: readonly unknown[];
|
|
||||||
federationModule: unknown;
|
|
||||||
bootLogLines: readonly string[];
|
|
||||||
mosaicConfig: MosaicConfig;
|
|
||||||
resolvedConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageTier = 'local' | 'standalone' | 'federated';
|
|
||||||
|
|
||||||
interface ModuleGraphFixture {
|
|
||||||
tempRoot: string;
|
|
||||||
anchor: string;
|
|
||||||
homePath: string;
|
|
||||||
cwdPath: string;
|
|
||||||
monorepoRootEnvPath: string;
|
|
||||||
gatewayLocalEnvPath: string;
|
|
||||||
daemonEnvPath: string;
|
|
||||||
monorepoRootConfigPath: string;
|
|
||||||
gatewayLocalConfigPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModuleGraphFixtureOptions {
|
|
||||||
rootEnvMode?: 'present' | 'absent';
|
|
||||||
rootTier?: StorageTier;
|
|
||||||
rootEnvContents?: string;
|
|
||||||
redactionMarker?: string;
|
|
||||||
gatewayLocalTier?: StorageTier;
|
|
||||||
gatewayLocalEnvContents?: string;
|
|
||||||
daemonEnvContents?: string;
|
|
||||||
inheritedTier?: StorageTier;
|
|
||||||
expectedProcessTier?: string;
|
|
||||||
setup?: (fixture: ModuleGraphFixture) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
|
||||||
const MONOREPO_ROOT_DOTENV_LABEL = 'monorepo-root .env';
|
|
||||||
const DAEMON_DOTENV_LABEL = 'daemon .env';
|
|
||||||
|
|
||||||
function configJson(tier: StorageTier): string {
|
|
||||||
if (tier === 'local') {
|
|
||||||
return JSON.stringify({
|
|
||||||
tier,
|
|
||||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
|
||||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
|
||||||
memory: { type: 'keyword' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
tier,
|
|
||||||
storage: { type: 'postgres', url: 'postgresql://fixture.invalid/mosaic' },
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: tier === 'federated' ? 'pgvector' : 'keyword' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
|
||||||
return { ...process.env };
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
|
||||||
for (const key of Object.keys(process.env)) {
|
|
||||||
if (!(key in snapshot)) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(snapshot)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
|
||||||
const relativePath = relative(tempRoot, path);
|
|
||||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, contents, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigModuleProvider {
|
|
||||||
provide: string;
|
|
||||||
useFactory: () => MosaicConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConfigModuleProvider(value: unknown): value is ConfigModuleProvider {
|
|
||||||
if (typeof value !== 'object' || value === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!('provide' in value) || typeof value.provide !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'useFactory' in value && typeof value.useFactory === 'function';
|
|
||||||
}
|
|
||||||
|
|
||||||
function singleBootLogLine(bootLogLines: readonly string[]): string {
|
|
||||||
expect(bootLogLines).toHaveLength(1);
|
|
||||||
const [bootLogLine] = bootLogLines;
|
|
||||||
if (bootLogLine === undefined) {
|
|
||||||
throw new Error('Expected a single boot log line');
|
|
||||||
}
|
|
||||||
|
|
||||||
return bootLogLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectBootLogLine(
|
|
||||||
bootLogLines: readonly string[],
|
|
||||||
tier: StorageTier,
|
|
||||||
source: string,
|
|
||||||
): void {
|
|
||||||
const bootLogLine = singleBootLogLine(bootLogLines);
|
|
||||||
|
|
||||||
expect(bootLogLine).toContain(`storage tier=${tier}`);
|
|
||||||
expect(bootLogLine).toContain(`source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModuleGraphFromDotenv(
|
|
||||||
options: ModuleGraphFixtureOptions,
|
|
||||||
): Promise<ComposedModuleGraph> {
|
|
||||||
const originalEnv = snapshotProcessEnv();
|
|
||||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-module-'));
|
|
||||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
|
||||||
const homePath = join(tempRoot, 'home');
|
|
||||||
const cwdPath = join(tempRoot, 'ambient', 'parent', 'cwd');
|
|
||||||
const fixture: ModuleGraphFixture = {
|
|
||||||
tempRoot,
|
|
||||||
anchor,
|
|
||||||
homePath,
|
|
||||||
cwdPath,
|
|
||||||
monorepoRootEnvPath: resolve(anchor, '../../..', '.env'),
|
|
||||||
gatewayLocalEnvPath: resolve(anchor, '..', '.env'),
|
|
||||||
daemonEnvPath: join(homePath, '.config', 'mosaic', 'gateway', '.env'),
|
|
||||||
monorepoRootConfigPath: resolve(anchor, '../../..', 'mosaic.config.json'),
|
|
||||||
gatewayLocalConfigPath: resolve(anchor, '..', 'mosaic.config.json'),
|
|
||||||
};
|
|
||||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
|
||||||
|
|
||||||
for (const path of Object.values(fixture)) {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(anchor, { recursive: true });
|
|
||||||
await mkdir(cwdPath, { recursive: true });
|
|
||||||
|
|
||||||
if ((options.rootEnvMode ?? 'present') === 'absent') {
|
|
||||||
if (
|
|
||||||
options.rootEnvContents !== undefined ||
|
|
||||||
options.rootTier !== undefined ||
|
|
||||||
options.redactionMarker !== undefined
|
|
||||||
) {
|
|
||||||
throw new Error('Expected no root env fixture values when rootEnvMode is absent');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (options.rootEnvContents === undefined && options.rootTier === undefined) {
|
|
||||||
throw new Error('Expected rootTier or rootEnvContents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootFixture = options.rootEnvContents ?? `MOSAIC_STORAGE_TIER=${options.rootTier}\n`;
|
|
||||||
const rootFixtureWithMarker = options.redactionMarker
|
|
||||||
? `${rootFixture}BETTER_AUTH_SECRET=${options.redactionMarker}\n`
|
|
||||||
: rootFixture;
|
|
||||||
await writeFixture(fixture.monorepoRootEnvPath, rootFixtureWithMarker, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.daemonEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.daemonEnvPath, options.daemonEnvContents, tempRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.gatewayLocalEnvContents !== undefined) {
|
|
||||||
await writeFixture(fixture.gatewayLocalEnvPath, options.gatewayLocalEnvContents, tempRoot);
|
|
||||||
} else if (options.gatewayLocalTier !== undefined) {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalEnvPath,
|
|
||||||
`MOSAIC_STORAGE_TIER=${options.gatewayLocalTier}\n`,
|
|
||||||
tempRoot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env['HOME'] = homePath;
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
delete process.env['MOSAIC_GATEWAY_HOME'];
|
|
||||||
|
|
||||||
await options.setup?.(fixture);
|
|
||||||
|
|
||||||
if (options.inheritedTier !== undefined) {
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] = options.inheritedTier;
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
|
||||||
vi.doMock('node:url', () => ({
|
|
||||||
...nodeUrl,
|
|
||||||
fileURLToPath: (url: string | URL): string => {
|
|
||||||
const actualPath = nodeUrl.fileURLToPath(url);
|
|
||||||
if (
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
|
||||||
) {
|
|
||||||
return join(anchor, 'env.ts');
|
|
||||||
}
|
|
||||||
return actualPath;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
|
||||||
|
|
||||||
if (options.inheritedTier === undefined) {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(options.inheritedTier);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envModule = await import('./env.js');
|
|
||||||
expect(process.env['MOSAIC_STORAGE_TIER']).toBe(
|
|
||||||
options.expectedProcessTier ?? options.rootTier,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { AppModule } = await import('./app.module.js');
|
|
||||||
const { FederationModule } = await import('./federation/federation.module.js');
|
|
||||||
const imports: unknown = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule);
|
|
||||||
|
|
||||||
if (!Array.isArray(imports)) {
|
|
||||||
throw new Error('AppModule imports metadata is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ConfigModule, MOSAIC_CONFIG } = await import('./config/config.module.js');
|
|
||||||
const providers: unknown = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ConfigModule);
|
|
||||||
|
|
||||||
if (!Array.isArray(providers)) {
|
|
||||||
throw new Error('ConfigModule providers metadata is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
const configProvider = providers
|
|
||||||
.filter(isConfigModuleProvider)
|
|
||||||
.find((provider: ConfigModuleProvider): boolean => provider.provide === MOSAIC_CONFIG);
|
|
||||||
|
|
||||||
if (!configProvider) {
|
|
||||||
throw new Error('MOSAIC_CONFIG provider factory not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
imports,
|
|
||||||
federationModule: FederationModule,
|
|
||||||
bootLogLines: consoleInfoSpy.mock.calls.map((args: readonly unknown[]): string =>
|
|
||||||
args.map((value: unknown): string => String(value)).join(' '),
|
|
||||||
),
|
|
||||||
mosaicConfig: configProvider.useFactory(),
|
|
||||||
resolvedConfigPath: envModule.resolveGatewayConfigPath(),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
cwdSpy?.mockRestore();
|
|
||||||
vi.doUnmock('node:url');
|
|
||||||
vi.doUnmock('node:os');
|
|
||||||
vi.resetModules();
|
|
||||||
consoleInfoSpy?.mockRestore();
|
|
||||||
restoreProcessEnv(originalEnv);
|
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AppModule federation gating', (): void => {
|
|
||||||
it('loads dotenv before tracing and AppModule evaluation', async (): Promise<void> => {
|
|
||||||
const mainSource = await readFile(new URL('./main.ts', import.meta.url), 'utf8');
|
|
||||||
const envImportIndex = mainSource.indexOf("import './env.js';");
|
|
||||||
const tracingImportIndex = mainSource.indexOf("import './tracing.js';");
|
|
||||||
const appModuleImportIndex = mainSource.indexOf("import { AppModule } from './app.module.js';");
|
|
||||||
|
|
||||||
expect(envImportIndex).toBeGreaterThan(-1);
|
|
||||||
expect(envImportIndex).toBeLessThan(tracingImportIndex);
|
|
||||||
expect(envImportIndex).toBeLessThan(appModuleImportIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores ambient cwd/.env and cwd/../.env files',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, '.env'),
|
|
||||||
'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(
|
|
||||||
resolve(fixture.cwdPath, '..', '.env'),
|
|
||||||
'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores an ambient cwd/mosaic.config.json federated config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'ignores an ambient cwd/../../mosaic.config.json federated config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
resolve(fixture.cwdPath, '../..', 'mosaic.config.json'),
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'anchored gateway-local config wins monorepo-root config and registers FederationModule',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let gatewayLocalConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
gatewayLocalConfigPath = fixture.gatewayLocalConfigPath;
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(gatewayLocalConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'resolves the daemon-installed GATEWAY_HOME/mosaic.config.json ahead of gateway-local and monorepo-root configs',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
let daemonConfigPath = '';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
const externalGatewayHome = join(fixture.tempRoot, 'external-gateway-home');
|
|
||||||
daemonConfigPath = join(externalGatewayHome, 'mosaic.config.json');
|
|
||||||
await writeFixture(daemonConfigPath, configJson('federated'), fixture.tempRoot);
|
|
||||||
await writeFixture(
|
|
||||||
fixture.gatewayLocalConfigPath,
|
|
||||||
configJson('standalone'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
await writeFixture(fixture.monorepoRootConfigPath, configJson('local'), fixture.tempRoot);
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] = externalGatewayHome;
|
|
||||||
process.env['DATABASE_URL'] = 'postgresql://fixture.invalid/mosaic';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.resolvedConfigPath).toBe(daemonConfigPath);
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'logs mosaic.config.json when anchored config and env tiers are both federated',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'federated',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.monorepoRootConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'mosaic.config.json');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'logs standalone from a monorepo-root .env DATABASE_URL fallback',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'attributes an invalid monorepo-root dotenv tier to the default',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
|
||||||
expectedProcessTier: 'invalid',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'attributes DATABASE_URL fallback to daemon .env ahead of inherited local tier',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
daemonEnvContents: 'DATABASE_URL=fixture-database-url\n',
|
|
||||||
inheritedTier: 'local',
|
|
||||||
expectedProcessTier: 'local',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'daemon .env wins over monorepo-root and gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=standalone\n',
|
|
||||||
expectedProcessTier: 'standalone',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', DAEMON_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'inherits process.env.MOSAIC_STORAGE_TIER over daemon, monorepo-root, and gateway-local dotenv values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
daemonEnvContents: 'MOSAIC_STORAGE_TIER=federated\n',
|
|
||||||
inheritedTier: 'standalone',
|
|
||||||
expectedProcessTier: 'standalone',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', 'process environment');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'gateway-local .env configures the tier and source when the monorepo-root .env is absent',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootEnvMode: 'absent',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
expectedProcessTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', 'gateway-local .env');
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'monorepo-root .env wins over gateway-local tier values',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'standalone',
|
|
||||||
gatewayLocalTier: 'federated',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'standalone', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['local', 'standalone'] as const)(
|
|
||||||
'does not register FederationModule for the %s tier',
|
|
||||||
async (tier): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({ rootTier: tier });
|
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, tier, MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'registers FederationModule when federated tier is supplied by the anchored monorepo root .env',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const redactionMarker = 'redaction-fixture-marker';
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'federated',
|
|
||||||
redactionMarker,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.imports).toContain(graph.federationModule);
|
|
||||||
expectBootLogLine(graph.bootLogLines, 'federated', MONOREPO_ROOT_DOTENV_LABEL);
|
|
||||||
expect(singleBootLogLine(graph.bootLogLines)).not.toContain(redactionMarker);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'MOSAIC_CONFIG provider ignores an ambient cwd/mosaic.config.json config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
join(fixture.cwdPath, 'mosaic.config.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
tier: 'federated',
|
|
||||||
storage: {
|
|
||||||
type: 'postgres',
|
|
||||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: 'pgvector' },
|
|
||||||
}),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('local');
|
|
||||||
expect(graph.mosaicConfig.storage).not.toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
'MOSAIC_CONFIG provider resolves from the anchored monorepo-root mosaic.config.json',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
|
||||||
rootTier: 'local',
|
|
||||||
setup: async (fixture: ModuleGraphFixture): Promise<void> => {
|
|
||||||
await writeFixture(
|
|
||||||
fixture.monorepoRootConfigPath,
|
|
||||||
configJson('federated'),
|
|
||||||
fixture.tempRoot,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(graph.mosaicConfig.tier).toBe('federated');
|
|
||||||
expect(graph.mosaicConfig.storage).toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://fixture.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -26,16 +26,6 @@ import { WorkspaceModule } from './workspace/workspace.module.js';
|
|||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { loadConfig } from '@mosaicstack/config';
|
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
// Federation (step-ca client, enrollment, federation verbs) is only wired for
|
|
||||||
// tier 'federated' — CaService hard-requires STEP_CA_* at construction, which
|
|
||||||
// must not gate standalone/local boots (docker-compose.federated.yml: the
|
|
||||||
// federation profile "must not start in non-federated dev"). The gateway
|
|
||||||
// entrypoint loads env.ts before evaluating this module so dotenv-backed tier
|
|
||||||
// configuration is visible here.
|
|
||||||
const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'federated';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -63,7 +53,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
FederationModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
@@ -12,6 +13,7 @@ const mockRegistry = {
|
|||||||
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
{ name: 'agent', aliases: ['a'], scope: 'agent', execution: 'socket', available: true },
|
||||||
{ name: 'prdy', aliases: [], 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: 'tools', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
|
{ name: 'mcp', aliases: [], scope: 'agent', execution: 'socket', available: true },
|
||||||
],
|
],
|
||||||
skills: [],
|
skills: [],
|
||||||
})),
|
})),
|
||||||
@@ -72,7 +74,14 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
function buildService(
|
||||||
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
|
mcpClient: {
|
||||||
|
reconnectServer: ReturnType<typeof vi.fn>;
|
||||||
|
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||||
|
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||||
|
} | null = null,
|
||||||
|
): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
@@ -82,7 +91,7 @@ function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecut
|
|||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
null,
|
mcpClient as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,4 +267,124 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,8 +159,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Command /${command} failed: ${err}`);
|
this.logger.error(`Command /${command} failed`, err);
|
||||||
return { command, conversationId, success: false, message: String(err) };
|
return {
|
||||||
|
command,
|
||||||
|
conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Command failed due to an internal error.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +341,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: newAgent.id, agentName: newAgent.name },
|
data: { agentId: newAgent.id, agentName: newAgent.name },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to create agent: ${err}`);
|
this.logger.error(`Failed to create agent "${namePart}" for user ${userId}`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to create agent: ${String(err)}`,
|
message: 'Failed to create agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -391,11 +396,11 @@ export class CommandExecutorService {
|
|||||||
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
|
this.logger.error(`Failed to switch agent "${agentName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to switch agent: ${String(err)}`,
|
message: 'Failed to switch agent due to an internal error.',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -608,11 +613,12 @@ export class CommandExecutorService {
|
|||||||
message: `MCP server "${serverName}" reconnected successfully.`,
|
message: `MCP server "${serverName}" reconnected successfully.`,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to reconnect MCP server "${serverName}"`, err);
|
||||||
return {
|
return {
|
||||||
command: 'mcp',
|
command: 'mcp',
|
||||||
conversationId,
|
conversationId,
|
||||||
success: false,
|
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.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
import { loadConfig, type MosaicConfig } from '@mosaicstack/config';
|
||||||
import { resolveGatewayConfigPath } from '../env.js';
|
|
||||||
|
|
||||||
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ export const MOSAIC_CONFIG = 'MOSAIC_CONFIG';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: MOSAIC_CONFIG,
|
provide: MOSAIC_CONFIG,
|
||||||
useFactory: (): MosaicConfig => loadConfig(resolveGatewayConfigPath()),
|
useFactory: (): MosaicConfig => loadConfig(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [MOSAIC_CONFIG],
|
exports: [MOSAIC_CONFIG],
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { detectFromEnv, loadConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
type TierSource =
|
|
||||||
| 'process environment'
|
|
||||||
| 'daemon .env'
|
|
||||||
| 'monorepo-root .env'
|
|
||||||
| 'gateway-local .env'
|
|
||||||
| 'default';
|
|
||||||
|
|
||||||
type BootSource = TierSource | 'mosaic.config.json';
|
|
||||||
|
|
||||||
export interface GatewayDotenvPaths {
|
|
||||||
daemonEnv: string;
|
|
||||||
monorepoRootEnv: string;
|
|
||||||
gatewayLocalEnv: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export function resolveGatewayDotenvPaths(
|
|
||||||
anchor: string = here,
|
|
||||||
homeBase: string = homedir(),
|
|
||||||
): GatewayDotenvPaths {
|
|
||||||
return {
|
|
||||||
daemonEnv: join(homeBase, '.config', 'mosaic', 'gateway', '.env'),
|
|
||||||
monorepoRootEnv: resolve(anchor, '../../..', '.env'),
|
|
||||||
gatewayLocalEnv: resolve(anchor, '..', '.env'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGatewayConfigPath(anchor: string = here): string {
|
|
||||||
// GATEWAY_HOME is daemon-created 0700; its env override adds no authority because env can set MOSAIC_STORAGE_TIER.
|
|
||||||
const gatewayHome = resolve(
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
|
||||||
);
|
|
||||||
const daemonConfig = join(gatewayHome, 'mosaic.config.json');
|
|
||||||
const gatewayLocalConfig = resolve(anchor, '..', 'mosaic.config.json');
|
|
||||||
const monorepoRootConfig = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
if (existsSync(daemonConfig)) {
|
|
||||||
return daemonConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(gatewayLocalConfig)) {
|
|
||||||
return gatewayLocalConfig;
|
|
||||||
}
|
|
||||||
if (existsSync(monorepoRootConfig)) {
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return monorepoRootConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadGatewayEnv(anchor: string = here, homeBase: string = homedir()): void {
|
|
||||||
const { daemonEnv, monorepoRootEnv, gatewayLocalEnv } = resolveGatewayDotenvPaths(
|
|
||||||
anchor,
|
|
||||||
homeBase,
|
|
||||||
);
|
|
||||||
const inheritedTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
let tierSource: TierSource = inheritedTier === undefined ? 'default' : 'process environment';
|
|
||||||
const inheritedDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
let databaseUrlSource: TierSource =
|
|
||||||
inheritedDatabaseUrl === undefined ? 'default' : 'process environment';
|
|
||||||
|
|
||||||
function loadAnchoredDotenv(
|
|
||||||
path: string,
|
|
||||||
sourceLabel: Exclude<TierSource, 'process environment' | 'default'>,
|
|
||||||
): void {
|
|
||||||
if (!existsSync(path)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const beforeDatabaseUrl = process.env['DATABASE_URL'];
|
|
||||||
config({ path, quiet: true });
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeTier === undefined &&
|
|
||||||
process.env['MOSAIC_STORAGE_TIER'] !== undefined &&
|
|
||||||
tierSource === 'default'
|
|
||||||
) {
|
|
||||||
tierSource = sourceLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
beforeDatabaseUrl === undefined &&
|
|
||||||
process.env['DATABASE_URL'] !== undefined &&
|
|
||||||
databaseUrlSource === 'default'
|
|
||||||
) {
|
|
||||||
databaseUrlSource = sourceLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load .env from daemon config dir (global install / daemon mode) first.
|
|
||||||
// It takes precedence over file-based local-dev configuration.
|
|
||||||
loadAnchoredDotenv(daemonEnv, 'daemon .env');
|
|
||||||
|
|
||||||
// Load .env from the anchored monorepo root, then fill any remaining values
|
|
||||||
// from apps/gateway/.env when present.
|
|
||||||
loadAnchoredDotenv(monorepoRootEnv, 'monorepo-root .env');
|
|
||||||
loadAnchoredDotenv(gatewayLocalEnv, 'gateway-local .env');
|
|
||||||
|
|
||||||
const envOnlyTier = detectFromEnv().tier;
|
|
||||||
const configPath = resolveGatewayConfigPath(anchor);
|
|
||||||
const anchoredConfigExists = existsSync(configPath);
|
|
||||||
const resolvedTier = loadConfig(configPath).tier;
|
|
||||||
const configuredTier = process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
const databaseUrlDeterminesTier = envOnlyTier === 'standalone' && configuredTier !== 'standalone';
|
|
||||||
const recognizedTierDeterminesTier =
|
|
||||||
(configuredTier === 'federated' ||
|
|
||||||
configuredTier === 'standalone' ||
|
|
||||||
configuredTier === 'local') &&
|
|
||||||
configuredTier === envOnlyTier;
|
|
||||||
|
|
||||||
let source: BootSource;
|
|
||||||
if (anchoredConfigExists) {
|
|
||||||
source = 'mosaic.config.json';
|
|
||||||
} else if (databaseUrlDeterminesTier && databaseUrlSource !== 'default') {
|
|
||||||
source = databaseUrlSource;
|
|
||||||
} else if (recognizedTierDeterminesTier && tierSource !== 'default') {
|
|
||||||
source = tierSource;
|
|
||||||
} else {
|
|
||||||
source = 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`[gateway env] storage tier=${resolvedTier} source=${source}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGatewayEnv();
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import * as nodeOs from 'node:os';
|
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
||||||
import * as nodeUrl from 'node:url';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
import type * as MosaicStorage from '@mosaicstack/storage';
|
|
||||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
|
||||||
|
|
||||||
// Each case uses vi.resetModules() and re-imports the full gateway graph for distinct ambient FS/env; CI needs headroom, while this still guards genuine hangs.
|
|
||||||
const MODULE_IMPORT_TIMEOUT_MS = 120_000;
|
|
||||||
|
|
||||||
function snapshotProcessEnv(): Record<string, string | undefined> {
|
|
||||||
return { ...process.env };
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreProcessEnv(snapshot: Record<string, string | undefined>): void {
|
|
||||||
for (const key of Object.keys(process.env)) {
|
|
||||||
if (!(key in snapshot)) {
|
|
||||||
delete process.env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(snapshot)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
delete process.env[key];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectPathUnderTempRoot(path: string, tempRoot: string): void {
|
|
||||||
const relativePath = relative(tempRoot, path);
|
|
||||||
expect(relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFixture(path: string, contents: string, tempRoot: string): Promise<void> {
|
|
||||||
expectPathUnderTempRoot(path, tempRoot);
|
|
||||||
await mkdir(dirname(path), { recursive: true });
|
|
||||||
await writeFile(path, contents, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BootstrapPreflightResult {
|
|
||||||
capturedConfig: MosaicConfig | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBootstrapPreflight(
|
|
||||||
anchoredConfigContents: string,
|
|
||||||
ambientConfigContents: string,
|
|
||||||
): Promise<BootstrapPreflightResult> {
|
|
||||||
const originalEnv = snapshotProcessEnv();
|
|
||||||
const tempRoot = await mkdtemp(join(nodeOs.tmpdir(), 'mosaic-gateway-main-preflight-'));
|
|
||||||
let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let exitSpy: MockInstance<typeof process.exit> | undefined;
|
|
||||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let capturedConfig: MosaicConfig | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const anchor = join(tempRoot, 'anchored', 'apps', 'gateway', 'src');
|
|
||||||
const homePath = join(tempRoot, 'home');
|
|
||||||
const cwdPath = join(tempRoot, 'ambient', 'cwd');
|
|
||||||
const monorepoRootConfigPath = resolve(anchor, '../../..', 'mosaic.config.json');
|
|
||||||
|
|
||||||
await mkdir(anchor, { recursive: true });
|
|
||||||
await mkdir(cwdPath, { recursive: true });
|
|
||||||
|
|
||||||
await writeFixture(monorepoRootConfigPath, anchoredConfigContents, tempRoot);
|
|
||||||
await writeFixture(join(cwdPath, 'mosaic.config.json'), ambientConfigContents, tempRoot);
|
|
||||||
|
|
||||||
process.env['HOME'] = homePath;
|
|
||||||
process.env['BETTER_AUTH_SECRET'] = 'fixture-secret';
|
|
||||||
delete process.env['MOSAIC_STORAGE_TIER'];
|
|
||||||
delete process.env['DATABASE_URL'];
|
|
||||||
delete process.env['VALKEY_URL'];
|
|
||||||
|
|
||||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation((): void => undefined);
|
|
||||||
const exitMock = vi.fn<typeof process.exit>();
|
|
||||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(exitMock);
|
|
||||||
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock('node:os', () => ({ ...nodeOs, homedir: (): string => homePath }));
|
|
||||||
vi.doMock('node:url', () => ({
|
|
||||||
...nodeUrl,
|
|
||||||
fileURLToPath: (url: string | URL): string => {
|
|
||||||
const actualPath = nodeUrl.fileURLToPath(url);
|
|
||||||
if (
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.ts') ||
|
|
||||||
actualPath.endsWith('/apps/gateway/src/env.js')
|
|
||||||
) {
|
|
||||||
return join(anchor, 'env.ts');
|
|
||||||
}
|
|
||||||
return actualPath;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdPath);
|
|
||||||
vi.doMock('./tracing.js', () => ({}));
|
|
||||||
|
|
||||||
const preflightSentinel = new Error('preflight-capture-sentinel');
|
|
||||||
vi.doMock('@mosaicstack/storage', async () => {
|
|
||||||
const actual = await vi.importActual<typeof MosaicStorage>('@mosaicstack/storage');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
detectAndAssertTier: vi.fn((config: MosaicConfig): Promise<void> => {
|
|
||||||
capturedConfig = config;
|
|
||||||
throw preflightSentinel;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await import('./main.js');
|
|
||||||
await vi.waitFor((): void => {
|
|
||||||
expect(exitSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { capturedConfig };
|
|
||||||
} finally {
|
|
||||||
cwdSpy?.mockRestore();
|
|
||||||
exitSpy?.mockRestore();
|
|
||||||
consoleInfoSpy?.mockRestore();
|
|
||||||
vi.doUnmock('@mosaicstack/storage');
|
|
||||||
vi.doUnmock('./tracing.js');
|
|
||||||
vi.doUnmock('node:url');
|
|
||||||
vi.doUnmock('node:os');
|
|
||||||
vi.resetModules();
|
|
||||||
restoreProcessEnv(originalEnv);
|
|
||||||
await rm(tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('main bootstrap preflight config anchoring', (): void => {
|
|
||||||
it(
|
|
||||||
'passes the anchored monorepo-root config to detectAndAssertTier, not an ambient cwd config',
|
|
||||||
async (): Promise<void> => {
|
|
||||||
const anchoredConfig = JSON.stringify({
|
|
||||||
tier: 'local',
|
|
||||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
|
||||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
|
||||||
memory: { type: 'keyword' },
|
|
||||||
});
|
|
||||||
const ambientConfig = JSON.stringify({
|
|
||||||
tier: 'federated',
|
|
||||||
storage: {
|
|
||||||
type: 'postgres',
|
|
||||||
url: 'postgresql://ambient-attacker.invalid/mosaic',
|
|
||||||
enableVector: true,
|
|
||||||
},
|
|
||||||
queue: { type: 'bullmq' },
|
|
||||||
memory: { type: 'pgvector' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { capturedConfig } = await runBootstrapPreflight(anchoredConfig, ambientConfig);
|
|
||||||
|
|
||||||
expect(capturedConfig?.tier).toBe('local');
|
|
||||||
expect(capturedConfig?.storage).not.toEqual(
|
|
||||||
expect.objectContaining({ url: 'postgresql://ambient-attacker.invalid/mosaic' }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import './env.js';
|
import { config } from 'dotenv';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// Load .env from daemon config dir (global install / daemon mode).
|
||||||
|
// Loaded first so monorepo .env can override for local dev.
|
||||||
|
const daemonEnv = join(homedir(), '.config', 'mosaic', 'gateway', '.env');
|
||||||
|
if (existsSync(daemonEnv)) config({ path: daemonEnv });
|
||||||
|
|
||||||
|
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
||||||
|
config({ path: resolve(process.cwd(), '../../.env') });
|
||||||
|
config(); // Also load apps/gateway/.env if present (overrides)
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -13,7 +26,6 @@ import { mountAuthHandler } from './auth/auth.controller.js';
|
|||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
@@ -25,7 +37,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// Pre-flight: assert all external services required by the configured tier
|
// Pre-flight: assert all external services required by the configured tier
|
||||||
// are reachable. Runs before NestFactory.create() so failures are visible
|
// are reachable. Runs before NestFactory.create() so failures are visible
|
||||||
// immediately with actionable remediation hints.
|
// immediately with actionable remediation hints.
|
||||||
const mosaicConfig = loadConfig(resolveGatewayConfigPath());
|
const mosaicConfig = loadConfig();
|
||||||
try {
|
try {
|
||||||
await detectAndAssertTier(mosaicConfig);
|
await detectAndAssertTier(mosaicConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
serverEntry.error = message;
|
serverEntry.error = 'Connection failed (see server logs).';
|
||||||
serverEntry.connected = false;
|
serverEntry.connected = false;
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload, SystemReloadPayload } from '@mosaicstack/types';
|
||||||
import { ReloadService } from './reload.service.js';
|
import { ReloadService } from './reload.service.js';
|
||||||
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
|
||||||
function createMockCommandRegistry() {
|
function createMockCommandRegistry() {
|
||||||
return {
|
return {
|
||||||
@@ -104,3 +107,79 @@ describe('ReloadService', () => {
|
|||||||
expect(() => service.registerPlugin('my-plugin', {})).not.toThrow();
|
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 executor = new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
mockAgentService as never,
|
||||||
|
mockSystemOverride as never,
|
||||||
|
mockSessionGC as never,
|
||||||
|
null,
|
||||||
|
mockBrain as never,
|
||||||
|
reloadService,
|
||||||
|
mockChatGateway as never,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
await plugin.onUnload();
|
||||||
reloaded.push(name);
|
reloaded.push(name);
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
await plugin.onLoad();
|
await plugin.onLoad();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${name}: load failed — ${err}`);
|
this.logger.error(`Plugin "${name}" failed during onLoad: ${err}`);
|
||||||
|
errors.push(`${name}: load failed (internal error)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/design-tokens": "workspace:^",
|
"@mosaicstack/design-tokens": "workspace:^",
|
||||||
|
"@mosaicstack/types": "workspace:^",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// 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,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
MessageAckPayload,
|
||||||
|
RoutingDecisionInfo,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SetThinkingPayload,
|
||||||
|
SkillCommandDef,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
|
SlashCommandPayload,
|
||||||
|
SlashCommandResultPayload,
|
||||||
|
SystemReloadPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AbortPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentStartPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ChatMessagePayload,
|
||||||
|
ClientToServerEvents,
|
||||||
|
CommandDef,
|
||||||
|
CommandManifest,
|
||||||
|
CommandManifestPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
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';
|
import { destroySocket, getSocket } from './socket';
|
||||||
|
|
||||||
describe('chat socket', () => {
|
interface MockChatSocket {
|
||||||
let disconnectHandler: (() => void) | undefined;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
function createMockSocket(): MockChatSocket {
|
||||||
disconnectHandler = undefined;
|
const handlers = new Map<string, Set<() => void>>();
|
||||||
ioMock.mockReset();
|
const mockSocket: MockChatSocket = {
|
||||||
|
|
||||||
const mockSocket = {
|
|
||||||
on: vi.fn((event: string, handler: () => void) => {
|
on: vi.fn((event: string, handler: () => void) => {
|
||||||
if (event === 'disconnect') disconnectHandler = handler;
|
if (!handlers.has(event)) handlers.set(event, new Set());
|
||||||
|
handlers.get(event)?.add(handler);
|
||||||
return mockSocket;
|
return mockSocket;
|
||||||
}),
|
}),
|
||||||
offAny: vi.fn(() => mockSocket),
|
offAny: vi.fn(() => mockSocket),
|
||||||
disconnect: vi.fn(() => mockSocket),
|
disconnect: vi.fn(() => mockSocket),
|
||||||
|
trigger(event: string): void {
|
||||||
|
for (const handler of handlers.get(event) ?? []) handler();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
return mockSocket;
|
||||||
|
}
|
||||||
|
|
||||||
ioMock.mockReturnValue(mockSocket);
|
let currentMock!: MockChatSocket;
|
||||||
|
|
||||||
|
describe('chat socket', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ioMock.mockReset();
|
||||||
|
// 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(() => {
|
afterEach(() => {
|
||||||
destroySocket();
|
destroySocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
it('creates one same-origin /chat namespace socket', () => {
|
||||||
const first = getSocket();
|
const first = getSocket();
|
||||||
const second = getSocket();
|
const second = getSocket();
|
||||||
|
|
||||||
@@ -44,9 +66,33 @@ describe('chat socket', () => {
|
|||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
disconnectHandler?.();
|
it('keeps the same singleton instance across a transient disconnect', () => {
|
||||||
getSocket();
|
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);
|
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) {
|
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', {
|
socket = io('/chat', {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
}) as unknown as ChatSocket;
|
||||||
|
|
||||||
// Reset singleton reference when socket is fully closed so the next
|
// A transient `disconnect` (network blip, server restart) must NOT null
|
||||||
// getSocket() call creates a fresh instance instead of returning a
|
// the singleton: socket.io-client auto-reconnects this same instance,
|
||||||
// closed/dead socket.
|
// and its listeners stay registered across that reconnect. Nulling here
|
||||||
socket.on('disconnect', () => {
|
// previously orphaned those listeners on the next getSocket() call by
|
||||||
socket = null;
|
// handing back a brand-new, unconnected instance. Only destroySocket()
|
||||||
});
|
// (an explicit, intentional teardown) may reset the singleton.
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-r
|
|||||||
import { LoginPage } from '@/spa/pages/login';
|
import { LoginPage } from '@/spa/pages/login';
|
||||||
import { RegisterPage } from '@/spa/pages/register';
|
import { RegisterPage } from '@/spa/pages/register';
|
||||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||||
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary';
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export const routes: RouteObject[] = [
|
|||||||
element: <AuthGuard />,
|
element: <AuthGuard />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||||
{ path: '/chat', element: <Placeholder title="Chat" /> },
|
{ path: '/chat', element: <ChatPage />, errorElement: <ChatRouteErrorBoundary /> },
|
||||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||||
|
|||||||
@@ -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,98 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactElement } from 'react';
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
onSend: (input: { content: string; provider?: string; modelId?: string }) => void;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
streaming,
|
||||||
|
sending,
|
||||||
|
hasConversation,
|
||||||
|
}: ComposerProps): ReactElement {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [provider, setProvider] = useState('');
|
||||||
|
const [modelId, setModelId] = useState('');
|
||||||
|
const busy = streaming || sending;
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (busy) return;
|
||||||
|
const trimmed = content.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onSend({
|
||||||
|
content: trimmed,
|
||||||
|
provider: provider.trim() || undefined,
|
||||||
|
modelId: modelId.trim() || undefined,
|
||||||
|
});
|
||||||
|
setContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-2 border-t p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Provider"
|
||||||
|
value={provider}
|
||||||
|
onChange={(event) => setProvider(event.target.value)}
|
||||||
|
placeholder="Provider (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Model"
|
||||||
|
value={modelId}
|
||||||
|
onChange={(event) => setModelId(event.target.value)}
|
||||||
|
placeholder="Model (optional)"
|
||||||
|
className="rounded border px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
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,49 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 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,125 @@
|
|||||||
|
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;
|
||||||
|
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. */
|
||||||
|
simulateReconnect(): 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,
|
||||||
|
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(): void {
|
||||||
|
socket.connected = true;
|
||||||
|
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
||||||
|
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
||||||
|
|
||||||
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
||||||
|
getSocketMock: vi.fn(),
|
||||||
|
destroySocketMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/socket', () => ({
|
||||||
|
getSocket: getSocketMock,
|
||||||
|
destroySocket: destroySocketMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatPage } from './chat';
|
||||||
|
|
||||||
|
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||||
|
const proto =
|
||||||
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectValue(el: HTMLSelectElement, value: string): void {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
||||||
|
setter?.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||||
|
candidate.textContent?.includes(text),
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Button with text "${text}" not found`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
||||||
|
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(async () => {
|
||||||
|
fake = createFakeChatSocket();
|
||||||
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
||||||
|
destroySocketMock.mockReset();
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<ChatPage />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChatPage', () => {
|
||||||
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
||||||
|
fake.serverEmit('agent:tool:start', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('pondering…');
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toContain('web_search');
|
||||||
|
expect(container.textContent).toMatch(/running/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:tool:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'web_search',
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
fake.serverEmit('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
||||||
|
cost: 0.02,
|
||||||
|
context: { percent: 3, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/success/i);
|
||||||
|
expect(container.textContent).toContain('Hello!');
|
||||||
|
expect(container.textContent).toMatch(/46/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('commands:manifest', {
|
||||||
|
manifest: {
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
aliases: ['m'],
|
||||||
|
description: 'Change the active model',
|
||||||
|
scope: 'core',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
routingDecision: {
|
||||||
|
model: 'claude',
|
||||||
|
provider: 'anthropic',
|
||||||
|
ruleName: 'default',
|
||||||
|
reason: 'default routing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('model');
|
||||||
|
expect(container.textContent).toContain('Change the active model');
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
expect(container.textContent).toContain('default routing');
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, 'high');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'set:thinking',
|
||||||
|
payload: { conversationId: 'c1', level: 'high' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'model');
|
||||||
|
setValue(argsInput, 'gpt-5');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toMatch(/approved/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||||
|
fake.serverEmit('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: 'Unknown model',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||||
|
expect(alertText).toContain('The model is unavailable');
|
||||||
|
// The structured, contract-provided denial reason is visibly rendered.
|
||||||
|
expect(alertText).toContain('Unknown model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('command:result', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'model',
|
||||||
|
success: false,
|
||||||
|
message: { bad: 'object' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||||
|
expect(alertText).toContain('Command failed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
||||||
|
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'level-0',
|
||||||
|
availableThinkingLevels: hostileLevels,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
const select = container.querySelector(
|
||||||
|
'select[aria-label="Thinking level"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
expect(select).toBeTruthy();
|
||||||
|
// A malformed level list still shows a visible, safe placeholder option
|
||||||
|
// rather than a silently empty select.
|
||||||
|
expect([...select.options]).toHaveLength(1);
|
||||||
|
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
selectValue(select, '');
|
||||||
|
});
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmitRaw('agent:end', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
usage: {
|
||||||
|
provider: { nested: 'object' },
|
||||||
|
modelId: undefined,
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
tokens: { total: 'not-a-number' },
|
||||||
|
cost: undefined,
|
||||||
|
context: { percent: null, window: 200000 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = container.querySelector('[aria-label="Usage"]');
|
||||||
|
expect(usage).toBeTruthy();
|
||||||
|
expect(usage?.textContent).toContain('tokens unavailable');
|
||||||
|
expect(usage?.textContent).toContain('cost unavailable');
|
||||||
|
expect(usage?.textContent).not.toContain('0 tokens');
|
||||||
|
expect(usage?.textContent).not.toContain('$0.0000');
|
||||||
|
expect(usage?.textContent).toContain('unknown/unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||||
|
node.textContent?.includes('Message accepted'),
|
||||||
|
);
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||||
|
// never the raw object.
|
||||||
|
expect(status?.textContent).toContain('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmitRaw('system:reload', {
|
||||||
|
commands: [],
|
||||||
|
skills: [],
|
||||||
|
providers: [],
|
||||||
|
message: { bad: 'object' },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
const status = container.querySelector('[role="status"]');
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
// A malformed reload message renders a stable, visible fallback rather
|
||||||
|
// than a silently empty status line.
|
||||||
|
expect(status?.textContent).toContain('Commands reloaded.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||||
|
await expect(
|
||||||
|
act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||||
|
}),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const providerInput = container.querySelector(
|
||||||
|
'input[aria-label="Provider"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
||||||
|
|
||||||
|
const stopButtonBefore = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonBefore.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello there');
|
||||||
|
setValue(providerInput, 'anthropic');
|
||||||
|
setValue(modelInput, 'claude');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'message',
|
||||||
|
payload: {
|
||||||
|
conversationId: undefined,
|
||||||
|
content: 'hello there',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain('hello there');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopButtonDuring = container.querySelector(
|
||||||
|
'button[aria-label="Stop"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(stopButtonDuring.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stopButtonDuring.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('session:info', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
provider: 'anthropic',
|
||||||
|
modelId: 'claude',
|
||||||
|
thinkingLevel: 'medium',
|
||||||
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'hello');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('error', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
error: 'Failed to start agent session. Please try again.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||||
|
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
||||||
|
expect(stopButton.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an accessible status once the message is acknowledged', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = [...container.querySelectorAll('[role="status"]')];
|
||||||
|
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
||||||
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('reasoning about it');
|
||||||
|
expect(container.textContent).toContain('Done.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandInput = container.querySelector(
|
||||||
|
'input[aria-label="Command name"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const argsInput = container.querySelector(
|
||||||
|
'input[aria-label="Command arguments"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(commandInput, 'deploy');
|
||||||
|
setValue(argsInput, 'prod');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setValue(argsInput, 'staging');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Request approval').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:approve',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('command:approval', {
|
||||||
|
conversationId: 'c1',
|
||||||
|
command: 'deploy',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'ap1',
|
||||||
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
findButton(container, 'Run approved command').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
||||||
|
expect(fake.emitted).toContainEqual({
|
||||||
|
event: 'command:execute',
|
||||||
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables sending a second message while a turn is streaming', async () => {
|
||||||
|
const textarea = container.querySelector(
|
||||||
|
'textarea[aria-label="Message"]',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'first');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||||
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendButton = findButton(container, 'Send');
|
||||||
|
expect(sendButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
setValue(textarea, 'second');
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
||||||
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
|
||||||
|
for (const [, handlers] of fake.listeners) {
|
||||||
|
expect(handlers.size).toBe(0);
|
||||||
|
}
|
||||||
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/** 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 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { RouteObject } from 'react-router-dom';
|
import type { RouteObject } from 'react-router-dom';
|
||||||
import { routes } from '@/routes';
|
import { routes } from '@/routes';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
|
import { ChatPage } from '@/spa/pages/chat';
|
||||||
|
|
||||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||||
return routeObjects.flatMap((route) => [
|
return routeObjects.flatMap((route) => [
|
||||||
@@ -55,4 +56,15 @@ describe('SPA route table', () => {
|
|||||||
expect(element.type).not.toBe(Placeholder);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
# WebUI Phase P — File / Folder Structure & Migration Map
|
|
||||||
|
|
||||||
> **Status:** living document — first pass. Structure and increment status are verified against
|
|
||||||
> `next` as of merge `8c27024d`. Details (per-surface component inventories, exact route tables,
|
|
||||||
> test matrices) are still being fleshed out; extend the stub sections below rather than rewriting
|
|
||||||
> the verified structure.
|
|
||||||
|
|
||||||
## 1. What Phase P is
|
|
||||||
|
|
||||||
Phase P migrates the Mosaic **web UI** (`apps/web`) from the legacy **Next.js App Router** app to a
|
|
||||||
**Vite + React Router single-page app (SPA)** that the **Gateway serves same-origin** on
|
|
||||||
`:14242`. The RFC splits the work into **six increments (P1–P6)**; the P1 PR title records this as
|
|
||||||
"increment 1/6".
|
|
||||||
|
|
||||||
The migration is deliberately **incremental and non-destructive**: the new SPA is built up
|
|
||||||
*beside* the existing Next app, sharing one `apps/web/src/lib` networking/auth layer, until the
|
|
||||||
final cutover (P5) removes the Next tree. At every point in between, **both app trees exist in the
|
|
||||||
same package** — this is intentional, not drift.
|
|
||||||
|
|
||||||
## 2. Current tree on `next` (dual-app, transitional)
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/web/
|
|
||||||
├── next.config.ts # legacy Next.js config (removed at P5)
|
|
||||||
├── vite.config.ts # SPA build + DEV proxy config (canonical from P5)
|
|
||||||
├── package.json # dev/build default to NEXT today; :vite variants opt in
|
|
||||||
└── src/
|
|
||||||
├── main.tsx # ── SPA entry (Vite)
|
|
||||||
├── routes.tsx # ── SPA React Router route table
|
|
||||||
├── spa/ # ── NEW SPA surfaces
|
|
||||||
│ ├── guards.tsx # guest / authenticated route guards
|
|
||||||
│ ├── pages/ # login, register, sso-callback (P2); chat + error boundary (P3)
|
|
||||||
│ └── chat/ # P3 typed chat: use-chat-connection, commands-panel,
|
|
||||||
│ # session-panel, message-transcript, tool-call-list, composer
|
|
||||||
│
|
|
||||||
├── lib/ # ── SHARED by BOTH trees (origin-relative networking + auth)
|
|
||||||
│ ├── api.ts # fetch wrapper — relative /api/...
|
|
||||||
│ ├── socket.ts # Socket.IO singleton — relative /chat
|
|
||||||
│ ├── auth-client.ts # BetterAuth client — relative /api/auth/...
|
|
||||||
│ ├── auth-redirect.ts # post-auth redirect resolution (protocol-relative rejected)
|
|
||||||
│ ├── chat-contract.ts # P3 typed chat wire contract (runtime-guarded)
|
|
||||||
│ ├── sso.ts · types.ts · cn.ts
|
|
||||||
│
|
|
||||||
├── app/ # ══ LEGACY Next.js App Router (removed at P5)
|
|
||||||
│ ├── (auth)/{login,register}/
|
|
||||||
│ ├── (dashboard)/{admin,chat,projects,projects/[id],settings,tasks}/
|
|
||||||
│ ├── auth/provider/[provider]/
|
|
||||||
│ └── layout.tsx · page.tsx · globals.css
|
|
||||||
│
|
|
||||||
├── components/ # ══ LEGACY Next component library (auth, chat, layout,
|
|
||||||
│ # projects, settings, tasks, ui) — ported into spa/ across P3/P4
|
|
||||||
└── providers/ # ══ theme-provider (legacy; SPA equivalent under providers)
|
|
||||||
```
|
|
||||||
|
|
||||||
Legend: `──` new SPA (keep), `══` legacy Next (removed at P5), shared `lib/` in the middle.
|
|
||||||
|
|
||||||
## 3. Networking / serving model (why it's same-origin)
|
|
||||||
|
|
||||||
- The SPA speaks **origin-relative paths only**: `/api/...`, `/api/auth/...`, `/chat`. No
|
|
||||||
`NEXT_PUBLIC_*` / `VITE_*` origin var, no hard-coded `http://localhost:14242` under
|
|
||||||
`apps/web/src`.
|
|
||||||
- **Dev:** `vite.config.ts` runs a dev-only proxy that forwards those paths to the Gateway (so the
|
|
||||||
SPA on its dev port and the Gateway on `:14242` behave as one origin).
|
|
||||||
- **Prod (target):** the SPA is **same-origin with the Gateway** — the Gateway serves the built
|
|
||||||
static bundle and the API/WS on `:14242`, so no proxy and no CORS. *(The Gateway does not serve
|
|
||||||
the web `dist` yet — adding that is the core of P5; see §5.)*
|
|
||||||
|
|
||||||
## 4. Build scripts (`apps/web/package.json`)
|
|
||||||
|
|
||||||
| Script | Today | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `dev` | `next dev` | legacy dev server |
|
|
||||||
| `dev:vite` | `vite` | SPA dev server (+ dev proxy) |
|
|
||||||
| `build` | `node ../../scripts/build-web.mjs` | currently a **Next** build |
|
|
||||||
| `build:vite` | `vite build` | SPA production build → `dist/` |
|
|
||||||
| `lint` / `typecheck` / `test` | `eslint src` / `tsc --noEmit` / `vitest run` | tree-agnostic |
|
|
||||||
|
|
||||||
At **P5** the `:vite` variants become the defaults (`dev`→vite, `build`→vite build) and the Next
|
|
||||||
build path is retired.
|
|
||||||
|
|
||||||
## 5. Increment map (P1–P6)
|
|
||||||
|
|
||||||
| # | Increment | Branch | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **P1** | Vite + React Router skeleton beside Next (entry, router, guards, vitest) | `feat/webui-p1-vite-skeleton` | ✅ merged — PR **#1143** |
|
|
||||||
| **P2** | SPA data layer + same-origin auth (login/register/SSO pages, guards, relative api/socket/auth-client) | `feat/webui-p2-data-auth` | ✅ merged — PR **#1144** |
|
|
||||||
| **P3** | Typed SPA **chat** (`spa/chat/*`, `chat-contract.ts`, chat page + error boundary) | `feat/webui-p3-chat` | 🚧 in progress (unmerged) |
|
|
||||||
| **P4** | Port **projects / tasks / settings / admin** dashboard surfaces into the SPA | _tbd_ | ⏳ not started |
|
|
||||||
| **P5** | **Cutover**: Gateway serves the Vite `dist` on `:14242`; flip `dev`/`build` to vite; **remove** the legacy Next `app/` tree + `next.config.ts` | _tbd_ | ⏳ not started |
|
|
||||||
| **P6** | CI / images (trails): build the SPA in CI, ship images | _tbd_ | ⏳ trails |
|
|
||||||
|
|
||||||
Each increment follows the same delivery pipeline: brief traceable to the RFC → author →
|
|
||||||
**independent** integrator verification (build+test+typecheck+lint) → **independent** code + security
|
|
||||||
review (author ≠ reviewer) → author remediates → branch + PR to `next` → **independent** merge-gate
|
|
||||||
merges. Author self-reports are not trusted; every gate is re-derived independently.
|
|
||||||
|
|
||||||
## 6. Known dependency / blocker
|
|
||||||
|
|
||||||
- **Issue #1145 — Gateway `dist` boot is broken** (DI failure on a defaulted constructor param);
|
|
||||||
the Gateway currently runs **dev-mode only**. This is a **hard precondition for P5**: the Gateway
|
|
||||||
cannot serve the SPA `dist` on `:14242` until `dist` boot works. P3/P4 remain on the dev-proxy
|
|
||||||
topology meanwhile.
|
|
||||||
|
|
||||||
## 7. Not part of Phase P (disambiguation)
|
|
||||||
|
|
||||||
`docs/plans/2026-08-09-webui-fleet-claude-bridge.md` and
|
|
||||||
`docs/scratchpads/webui-fleet-bridge-plan.md` describe a **separate** WebUI ↔ fleet/Claude bridge
|
|
||||||
effort. They are **not** the Phase P SPA migration and should not be conflated with the increments
|
|
||||||
above.
|
|
||||||
|
|
||||||
## 8. Where the detail lives (extend these)
|
|
||||||
|
|
||||||
- Per-increment working notes: `docs/scratchpads/webui-p*-*.md` (e.g. `webui-p2-data-auth.md`).
|
|
||||||
- _Stub — to flesh out:_ per-surface component inventory (which `components/*` port to which
|
|
||||||
`spa/*`), the full SPA route table, the P5 cutover checklist, and the P6 CI/image plan.
|
|
||||||
Generated
+3
Reference in New Issue
Block a user