@@ -8,6 +8,7 @@
|
||||
* to avoid real I/O — they verify the complete classify → match → decide path.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import { DEFAULT_ROUTING_RULES } from '../routing/default-rules.js';
|
||||
import type { RoutingRule } from './routing.types.js';
|
||||
@@ -17,7 +18,7 @@ import type { RoutingRule } from './routing.types.js';
|
||||
/** Build a RoutingEngineService backed by the given rule set and health map. */
|
||||
function makeService(
|
||||
rules: RoutingRule[],
|
||||
healthMap: Record<string, { status: string }>,
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }>,
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -67,11 +68,11 @@ function defaultRules(): RoutingRule[] {
|
||||
}
|
||||
|
||||
/** A health map where anthropic, openai, and zai are all healthy. */
|
||||
const allHealthy: Record<string, { status: string }> = {
|
||||
anthropic: { status: 'up' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
ollama: { status: 'up' },
|
||||
const allHealthy: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
ollama: { status: 'healthy' },
|
||||
};
|
||||
|
||||
// ─── M4-013 E2E tests ─────────────────────────────────────────────────────────
|
||||
@@ -212,10 +213,10 @@ describe('M4-013: routing end-to-end pipeline', () => {
|
||||
// Let's use a simple coding message to target Simple coding → Codex (openai)
|
||||
const message = 'implement a sort function';
|
||||
|
||||
const unhealthyHealth = {
|
||||
const unhealthyHealth: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'down' },
|
||||
openai: { status: 'up' },
|
||||
zai: { status: 'up' },
|
||||
openai: { status: 'healthy' },
|
||||
zai: { status: 'healthy' },
|
||||
ollama: { status: 'down' },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaicstack/db';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
import { classifyTask } from './task-classifier.js';
|
||||
@@ -49,7 +50,7 @@ export class RoutingEngineService {
|
||||
async resolve(
|
||||
message: string,
|
||||
userId?: string,
|
||||
availableProviders?: Record<string, { status: string }>,
|
||||
availableProviders?: Record<string, { status: ProviderHealthStatus }>,
|
||||
): Promise<RoutingDecision> {
|
||||
const classification = classifyTask(message);
|
||||
this.logger.debug(
|
||||
@@ -69,9 +70,8 @@ export class RoutingEngineService {
|
||||
if (!this.matchConditions(rule, classification)) continue;
|
||||
|
||||
const providerStatus = health[rule.action.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
|
||||
if (!isHealthy) {
|
||||
if (!this.isRoutable(providerStatus)) {
|
||||
this.logger.debug(
|
||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||
);
|
||||
@@ -111,6 +111,10 @@ export class RoutingEngineService {
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private isRoutable(status: ProviderHealthStatus | undefined): boolean {
|
||||
return status === 'healthy' || status === 'degraded';
|
||||
}
|
||||
|
||||
private evaluateCondition(
|
||||
condition: RoutingCondition,
|
||||
classification: TaskClassification,
|
||||
@@ -186,11 +190,12 @@ export class RoutingEngineService {
|
||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||
* If none are healthy, return the first entry unconditionally (last resort).
|
||||
*/
|
||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
||||
private applyFallbackChain(
|
||||
health: Record<string, { status: ProviderHealthStatus }>,
|
||||
): RoutingDecision {
|
||||
for (const candidate of FALLBACK_CHAIN) {
|
||||
const providerStatus = health[candidate.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
if (isHealthy) {
|
||||
if (this.isRoutable(providerStatus)) {
|
||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||
return {
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ProviderHealthStatus } from '@mosaicstack/types';
|
||||
import { RoutingEngineService } from './routing-engine.service.js';
|
||||
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
||||
|
||||
@@ -29,7 +30,7 @@ function makeClassification(overrides: Partial<TaskClassification> = {}): TaskCl
|
||||
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
||||
function makeService(
|
||||
rules: RoutingRule[] = [],
|
||||
healthMap: Record<string, { status: string }> = {},
|
||||
healthMap: Record<string, { status: ProviderHealthStatus }> = {},
|
||||
): RoutingEngineService {
|
||||
const mockDb = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -217,7 +218,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('high priority');
|
||||
@@ -241,7 +245,10 @@ describe('RoutingEngineService.resolve — priority ordering', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('coding rule');
|
||||
@@ -270,7 +277,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // primary is unhealthy
|
||||
openai: { status: 'up' },
|
||||
openai: { status: 'healthy' },
|
||||
});
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
@@ -290,7 +297,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
];
|
||||
|
||||
const service2 = makeService(unhealthyRules, {
|
||||
anthropic: { status: 'up' },
|
||||
anthropic: { status: 'healthy' },
|
||||
openai: { status: 'down' },
|
||||
});
|
||||
|
||||
@@ -306,7 +313,7 @@ describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
||||
|
||||
const service = makeService(rules, {
|
||||
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
||||
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
||||
ollama: { status: 'healthy' }, // Haiku is also on anthropic — use Ollama as next
|
||||
});
|
||||
|
||||
const decision = await service.resolve('hello there');
|
||||
@@ -345,7 +352,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const decision = await service.resolve('completely unrelated message xyz');
|
||||
expect(decision.ruleName).toBe('catch-all');
|
||||
@@ -369,7 +376,7 @@ describe('RoutingEngineService.resolve — empty conditions (fallback rule)', ()
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const codingDecision = await service.resolve('implement a function');
|
||||
expect(codingDecision.ruleName).toBe('specific coding rule');
|
||||
@@ -401,7 +408,7 @@ describe('RoutingEngineService.resolve — disabled rules', () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const service = makeService(rules, { anthropic: { status: 'up' } });
|
||||
const service = makeService(rules, { anthropic: { status: 'healthy' } });
|
||||
|
||||
const decision = await service.resolve('implement a function');
|
||||
expect(decision.ruleName).toBe('enabled fallback');
|
||||
@@ -452,9 +459,45 @@ describe('RoutingEngineService.resolve — availableProviders override', () => {
|
||||
ps: unknown,
|
||||
) => RoutingEngineService)(mockDb, mockProviderService);
|
||||
|
||||
const preSupplied = { anthropic: { status: 'up' } };
|
||||
const preSupplied: Record<string, { status: ProviderHealthStatus }> = {
|
||||
anthropic: { status: 'healthy' },
|
||||
};
|
||||
await service.resolve('implement a function', undefined, preSupplied);
|
||||
|
||||
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolve — canonical ProviderHealthStatus values ──────────────────────────
|
||||
|
||||
describe('RoutingEngineService.resolve — canonical health status routing', () => {
|
||||
it('routes healthy and degraded providers by rule, and falls through to fallback when down', async () => {
|
||||
const codingRule = makeRule({
|
||||
name: 'coding rule',
|
||||
priority: 1,
|
||||
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
||||
action: { provider: 'openai', model: 'gpt-4o' },
|
||||
});
|
||||
|
||||
// healthy → selected by its own rule, not the fallback chain
|
||||
const healthyService = makeService([codingRule], { openai: { status: 'healthy' } });
|
||||
const healthyDecision = await healthyService.resolve('implement a function');
|
||||
expect(healthyDecision.ruleName).toBe('coding rule');
|
||||
expect(healthyDecision.provider).toBe('openai');
|
||||
|
||||
// down → rule is skipped as unroutable, falls through to the fallback chain
|
||||
const downService = makeService([codingRule], {
|
||||
openai: { status: 'down' },
|
||||
anthropic: { status: 'healthy' },
|
||||
});
|
||||
const downDecision = await downService.resolve('implement a function');
|
||||
expect(downDecision.ruleName).toBe('fallback');
|
||||
expect(downDecision.provider).toBe('anthropic');
|
||||
|
||||
// degraded → still routable, selected by its own rule, not the fallback chain
|
||||
const degradedService = makeService([codingRule], { openai: { status: 'degraded' } });
|
||||
const degradedDecision = await degradedService.resolve('implement a function');
|
||||
expect(degradedDecision.ruleName).toBe('coding rule');
|
||||
expect(degradedDecision.provider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,13 +74,19 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
reconnectServer: ReturnType<typeof vi.fn>;
|
||||
getServerStatuses: ReturnType<typeof vi.fn>;
|
||||
getToolDefinitions: ReturnType<typeof vi.fn>;
|
||||
} | null = null,
|
||||
} = mockMcpClient,
|
||||
): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
|
||||
@@ -36,6 +36,12 @@ const authorization = {
|
||||
),
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
@@ -46,7 +52,7 @@ function buildExecutor(authorizationService: unknown = authorization): CommandEx
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mockMcpClient as never,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
@@ -548,15 +546,6 @@ export class CommandExecutorService {
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!this.mcpClient) {
|
||||
return {
|
||||
command: 'mcp',
|
||||
conversationId,
|
||||
success: false,
|
||||
message: 'MCP client service is not available.',
|
||||
};
|
||||
}
|
||||
|
||||
const action = args?.trim().split(/\s+/)[0] ?? 'status';
|
||||
|
||||
switch (action) {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* - Unknown command returns descriptive error
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CommandsModule } from './commands.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
@@ -47,6 +49,12 @@ const mockBrain = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -65,7 +73,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
mockBrain as never,
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
null, // mcpClient (optional)
|
||||
mockMcpClient as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +161,15 @@ describe('CommandRegistryService — integration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Module Wiring Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandsModule — Nest wiring', () => {
|
||||
it('CommandsModule imports McpClientModule in its Nest metadata', () => {
|
||||
const imports = Reflect.getMetadata('imports', CommandsModule) ?? [];
|
||||
expect(imports).toContain(McpClientModule);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Executor Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('CommandExecutorService — integration', () => {
|
||||
@@ -259,4 +276,14 @@ describe('CommandExecutorService — integration', () => {
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
// /mcp status reaches the required McpClientService and never reports it unavailable
|
||||
it('/mcp status calls the wired McpClientService and reports the no-servers message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mcp', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockMcpClient.getServerStatuses).toHaveBeenCalledOnce();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('No MCP servers configured.');
|
||||
expect(result.message).not.toBe('MCP client service is not available.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
@@ -14,7 +15,12 @@ import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
|
||||
@Module({
|
||||
imports: [GCModule, forwardRef(() => ReloadModule), forwardRef(() => ChatModule)],
|
||||
imports: [
|
||||
GCModule,
|
||||
McpClientModule,
|
||||
forwardRef(() => ReloadModule),
|
||||
forwardRef(() => ChatModule),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
|
||||
@@ -143,6 +143,12 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
const mockSessionGC = { sweepOrphans: vi.fn() };
|
||||
const mockBrain = { agents: { findByName: vi.fn(), findById: vi.fn(), create: vi.fn() } };
|
||||
|
||||
const mockMcpClient = {
|
||||
getServerStatuses: vi.fn(() => []),
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const executor = new CommandExecutorService(
|
||||
registry as never,
|
||||
mockAgentService as never,
|
||||
@@ -152,7 +158,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
mockBrain as never,
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
null,
|
||||
mockMcpClient as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# P3-R1 — Routing Health Enum + `/mcp` Wiring Scratchpad
|
||||
|
||||
**Task:** P3 hands-on acceptance blockers #1 and #5
|
||||
**Mission:** `mvp-20260312` (active)
|
||||
**Branch:** `feat/webui-p3r1-routing-mcp` from `origin/next`
|
||||
**Required base:** `20718b5a273d243363a4f5cbef5bbf692a805bdb`
|
||||
**Tracking ref:** Direct P3-R1 author brief; no provider issue supplied; no PR or merge authorized
|
||||
**Started:** 2026-08-11T14:52:52-05:00
|
||||
|
||||
## Objective
|
||||
|
||||
Fix exactly two P3 acceptance blockers:
|
||||
|
||||
1. Make routing consume the canonical `ProviderHealthStatus` enum, with `healthy` and `degraded` routable and `down` non-routable, at both routing decision sites.
|
||||
2. Wire `McpClientModule` into `CommandsModule`, make `McpClientService` required, remove the unreachable unavailable-service branch, and prove `/mcp status` reaches the client.
|
||||
|
||||
Explicitly excluded: provider registry/adapters, `provider.service.ts`, `agent.service.ts`, `chat.gateway.ts`, fallback membership, task classification, selector UI, conversation resume, reload UX, WS/origin/handshake behavior, dependencies/lockfile, and `apps/web/**` changes.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Confirm exact base/branch and inspect every cited source/test anchor plus all direct `CommandExecutorService` construction sites.
|
||||
2. Record baseline gateway and focused routing/commands/MCP test totals.
|
||||
3. Add regression tests first and run focused tests to capture expected RED failures.
|
||||
4. Apply only the typed routing helper/signature changes and required MCP module/constructor/guard changes; update impossible `up` fixtures.
|
||||
5. Run focused tests, all user-required verification gates, lockfile/scope/diff checks, and record counts.
|
||||
6. Obtain independent spec and code/security review; remediate any findings and repeat affected gates.
|
||||
7. Commit conventionally, run the pre-push queue guard, and push only the feature branch (no PR or merge).
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working soft cap: **30K tokens**, based on two bounded gateway bug fixes, focused TDD, full gateway/root verification, independent review, and branch delivery. One coding worker will execute serially; reviews will be independent and serial to avoid worktree collisions.
|
||||
|
||||
## Startup Evidence
|
||||
|
||||
- `git fetch origin` rc=0.
|
||||
- `origin/next` confirmed exactly `20718b5a273d243363a4f5cbef5bbf692a805bdb`.
|
||||
- Local and remote `feat/webui-p3r1-routing-mcp` were absent before creation.
|
||||
- Branch creation rc=0; HEAD equals the required base.
|
||||
- Harness-owned `.mosaic/orchestrator/session.lock` was already dirty and remains excluded from staging/commit.
|
||||
|
||||
## Baseline Evidence
|
||||
|
||||
- Gateway full suite: rc=0; **64 files / 693 tests passed**, 7 files / 17 tests skipped (71 files / 710 tests total).
|
||||
- Routing sub-suite (`src/agent/routing`): rc=0; **3 files / 105 tests passed**.
|
||||
- Commands/MCP sub-suite (`src/commands`, `src/mcp-client`): rc=0; **6 files / 76 tests passed**.
|
||||
|
||||
## TDD and Implementation Evidence
|
||||
|
||||
- Routing RED: after canonical fixture/test changes but before the service fix, focused routing run returned rc=1 with **15 failed / 91 passed (106)** because the old `up`/`ok` gates rejected `healthy` and `degraded`.
|
||||
- Routing GREEN: canonical `ProviderHealthStatus` map types, one `isRoutable` helper, and both comparison sites corrected; focused routing suite passed.
|
||||
- MCP behavior test now drives `/mcp status` through a required mock client and asserts the client is called, success is returned for zero servers, and the former unavailable message is absent.
|
||||
- MCP wiring mutation RED used the final `Reflect.getMetadata('imports', CommandsModule)` assertion with only the production `McpClientModule` import/registration temporarily removed: rc=1, exact failure `expected [GCModule, …] to include McpClientModule`.
|
||||
- MCP wiring GREEN after byte-for-byte production restoration: rc=0. Temporary mutation did not remain.
|
||||
- Every direct `new CommandExecutorService(...)` test construction now supplies a non-null MCP client mock.
|
||||
- Initial Codex worker launch failed rc=1 from missing OpenAI bearer authentication. First Mosaic Claude launch failed rc=1 because Distrobox-local runtime contracts were absent; retry with the supported host `MOSAIC_HOME=/home/jwoltje/.config/mosaic` succeeded.
|
||||
|
||||
## Review Evidence
|
||||
|
||||
- Independent spec review: **approve**, 0 blockers, scope OK.
|
||||
- Independent code/security review: **approve**, 0 blockers, 0 critical/high security findings. It suggested an actual module-wiring assertion, which was added with valid mutation RED/GREEN evidence.
|
||||
- Independent final re-review after remediation: **approve**, 0 blockers, 0 critical/high security findings, no remaining findings.
|
||||
- Optional missing-provider/`undefined` test suggestion was not adopted: the brief explicitly requires the three canonical statuses (`healthy`, `degraded`, `down`) and forbids scope expansion; runtime behavior for absent keys remains `undefined` → non-routable through the required helper signature.
|
||||
|
||||
## Documentation Assessment
|
||||
|
||||
- `docs/PRD.md` already requires provider fallback/routing and MCP capability; this increment restores implementation to those existing contracts.
|
||||
- No public API endpoint, payload schema, auth/permission rule, navigation, deployment procedure, or new user workflow changes. OpenAPI, endpoint index, user/admin/developer guides, and sitemap are therefore N/A for this bounded repair.
|
||||
- This append-only scratchpad is the implementation, TDD, review, and verification record. Canonical docs remain in-repo; no publishing action is in scope.
|
||||
|
||||
## Final Verification Evidence
|
||||
|
||||
All required and repository-situational gates completed with rc=0:
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `pnpm install --frozen-lockfile` | rc=0 |
|
||||
| Gateway typecheck | rc=0 |
|
||||
| Gateway lint | rc=0 |
|
||||
| Routing focused suite | rc=0; 3 files / 106 tests |
|
||||
| Commands/MCP focused suite | rc=0; 6 files / 78 tests |
|
||||
| Gateway full suite | rc=0; 64 files / 696 tests passed; 7 files / 17 tests skipped |
|
||||
| Gateway build | rc=0 |
|
||||
| Root typecheck | rc=0; 45/45 tasks |
|
||||
| Web test | rc=0; 19 files / 154 tests |
|
||||
| Root lint | rc=0; 25/25 tasks |
|
||||
| Root format check | rc=0 |
|
||||
| `git diff --check` | rc=0 |
|
||||
|
||||
- Gateway suite before→after: **693→696 passing tests**; skipped remained 17 (total 710→713).
|
||||
- Routing focused before→after: **105→106 passing tests**.
|
||||
- Commands/MCP focused before→after: **76→78 passing tests**.
|
||||
- `pnpm-lock.yaml` SHA-256 before/after frozen install: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; diff versus `origin/next` rc=0.
|
||||
- Verified no changed path under `apps/web/**`, provider service/adapters, `agent.service.ts`, `chat.gateway.ts`, or lockfile.
|
||||
- Verified both `McpClientModule` production wiring lines remain and no impossible `up`/`ok` routing status checks/fixtures remain.
|
||||
- Verified code/test diff SHA-256: `27b41a855084b9dd85a7bc2a79fa3251d114ee226a4f1b835e65134c25a4f5a8`.
|
||||
|
||||
## Delivery State
|
||||
|
||||
Implementation, testing, documentation assessment, and independent review are complete. Remaining authorized actions: format this final scratchpad append, create one conventional commit, run the required push queue guard, and push only `feat/webui-p3r1-routing-mcp`; no PR or merge.
|
||||
Reference in New Issue
Block a user