feat(gateway): CommandRegistryService + CommandExecutorService (P8-010) (#178)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #178.
This commit is contained in:
@@ -17,6 +17,7 @@ import { SkillsModule } from './skills/skills.module.js';
|
||||
import { PluginModule } from './plugin/plugin.module.js';
|
||||
import { McpModule } from './mcp/mcp.module.js';
|
||||
import { AdminModule } from './admin/admin.module.js';
|
||||
import { CommandsModule } from './commands/commands.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
@@ -38,6 +39,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
PluginModule,
|
||||
McpModule,
|
||||
AdminModule,
|
||||
CommandsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import type { Auth } from '@mosaic/auth';
|
||||
import type { SetThinkingPayload } from '@mosaic/types';
|
||||
import type { SetThinkingPayload, SlashCommandPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
@@ -38,6 +40,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(AUTH) private readonly auth: Auth,
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -55,6 +59,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Broadcast command manifest to the newly connected client
|
||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
@@ -184,6 +191,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('command:execute')
|
||||
async handleCommandExecute(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||
if (!client.connected) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { ChatGateway } from './chat.gateway.js';
|
||||
import { ChatController } from './chat.controller.js';
|
||||
|
||||
@Module({
|
||||
imports: [CommandsModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatGateway],
|
||||
})
|
||||
|
||||
127
apps/gateway/src/commands/command-executor.service.ts
Normal file
127
apps/gateway/src/commands/command-executor.service.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CommandExecutorService {
|
||||
private readonly logger = new Logger(CommandExecutorService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(CommandRegistryService) private readonly registry: CommandRegistryService,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, _userId: string): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Unknown command: /${command}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Start a new conversation by selecting New Conversation.',
|
||||
};
|
||||
case 'clear':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Conversation display cleared.',
|
||||
};
|
||||
case 'compact':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Context compaction requested.',
|
||||
};
|
||||
case 'retry':
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Retry last message requested.',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
command,
|
||||
conversationId,
|
||||
success: false,
|
||||
message: `Command /${command} is not yet implemented.`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Command /${command} failed: ${err}`);
|
||||
return { command, conversationId, success: false, message: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /model <model-name>',
|
||||
};
|
||||
}
|
||||
// Update agent session model if session is active
|
||||
// For now, acknowledge the request — full wiring done in P8-012
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested. No active session for this conversation.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Model switch to "${args}" requested.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleThinking(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const level = args?.toLowerCase();
|
||||
if (!level || !['none', 'low', 'medium', 'high', 'auto'].includes(level)) {
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: 'Usage: /thinking <none|low|medium|high|auto>',
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'thinking',
|
||||
conversationId,
|
||||
success: true,
|
||||
message: `Thinking level set to "${level}".`,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
53
apps/gateway/src/commands/command-registry.service.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import type { CommandDef } from '@mosaic/types';
|
||||
|
||||
const mockCmd: CommandDef = {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
aliases: ['t'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
};
|
||||
|
||||
describe('CommandRegistryService', () => {
|
||||
let service: CommandRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CommandRegistryService();
|
||||
});
|
||||
|
||||
it('starts with empty manifest', () => {
|
||||
expect(service.getManifest().commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('registers a command', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates existing command by name', () => {
|
||||
service.registerCommand(mockCmd);
|
||||
service.registerCommand({ ...mockCmd, description: 'Updated' });
|
||||
expect(service.getManifest().commands).toHaveLength(1);
|
||||
expect(service.getManifest().commands[0]?.description).toBe('Updated');
|
||||
});
|
||||
|
||||
it('onModuleInit registers core commands', () => {
|
||||
service.onModuleInit();
|
||||
const manifest = service.getManifest();
|
||||
expect(manifest.commands.length).toBeGreaterThan(5);
|
||||
expect(manifest.commands.some((c) => c.name === 'model')).toBe(true);
|
||||
expect(manifest.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest includes skills array', () => {
|
||||
const manifest = service.getManifest();
|
||||
expect(Array.isArray(manifest.skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('manifest version is 1', () => {
|
||||
expect(service.getManifest().version).toBe(1);
|
||||
});
|
||||
});
|
||||
177
apps/gateway/src/commands/command-registry.service.ts
Normal file
177
apps/gateway/src/commands/command-registry.service.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import type { CommandDef, CommandManifest } from '@mosaic/types';
|
||||
|
||||
@Injectable()
|
||||
export class CommandRegistryService implements OnModuleInit {
|
||||
private readonly commands: CommandDef[] = [];
|
||||
|
||||
registerCommand(def: CommandDef): void {
|
||||
const existing = this.commands.findIndex((c) => c.name === def.name);
|
||||
if (existing >= 0) {
|
||||
this.commands[existing] = def;
|
||||
} else {
|
||||
this.commands.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
registerCommands(defs: CommandDef[]): void {
|
||||
for (const def of defs) {
|
||||
this.registerCommand(def);
|
||||
}
|
||||
}
|
||||
|
||||
getManifest(): CommandManifest {
|
||||
return {
|
||||
version: 1,
|
||||
commands: [...this.commands],
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.registerCommands([
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Switch the active model',
|
||||
aliases: ['m'],
|
||||
args: [
|
||||
{
|
||||
name: 'model-name',
|
||||
type: 'string',
|
||||
optional: false,
|
||||
description: 'Model name to switch to',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'thinking',
|
||||
description: 'Set thinking level (none/low/medium/high/auto)',
|
||||
aliases: ['t'],
|
||||
args: [
|
||||
{
|
||||
name: 'level',
|
||||
type: 'enum',
|
||||
optional: false,
|
||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
||||
description: 'Thinking level',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
description: 'Start a new conversation',
|
||||
aliases: ['n'],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear conversation context and GC session artifacts',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
description: 'Request context compaction',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'retry',
|
||||
description: 'Retry the last message',
|
||||
aliases: [],
|
||||
scope: 'core',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'rename',
|
||||
description: 'Rename current conversation',
|
||||
aliases: [],
|
||||
args: [
|
||||
{ name: 'name', type: 'string', optional: false, description: 'New conversation name' },
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
description: 'Show conversation history',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description: 'Number of messages to show',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
description: 'Export conversation to markdown or JSON',
|
||||
aliases: [],
|
||||
args: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['md', 'json'],
|
||||
description: 'Export format',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'preferences',
|
||||
description: 'View or set user preferences',
|
||||
aliases: ['pref'],
|
||||
args: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'enum',
|
||||
optional: true,
|
||||
values: ['show', 'set', 'reset'],
|
||||
description: 'Action to perform',
|
||||
},
|
||||
],
|
||||
scope: 'core',
|
||||
execution: 'rest',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
description: 'Show session and connection status',
|
||||
aliases: ['s'],
|
||||
scope: 'core',
|
||||
execution: 'hybrid',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show available commands',
|
||||
aliases: ['h'],
|
||||
scope: 'core',
|
||||
execution: 'local',
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
9
apps/gateway/src/commands/commands.module.ts
Normal file
9
apps/gateway/src/commands/commands.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CommandRegistryService, CommandExecutorService],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule {}
|
||||
Reference in New Issue
Block a user