feat(M5-004,M5-005,M5-006,M5-007): session-conversation binding, session:info broadcast, agent creation from TUI, and session metrics (#321)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #321.
This commit is contained in:
2026-03-23 00:58:07 +00:00
committed by jason.woltje
parent b18976a7aa
commit 1035d13fc0
10 changed files with 3159 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
import { forwardRef, Inject, Injectable, Logger, Optional } from '@nestjs/common';
import type { QueueHandle } from '@mosaic/queue';
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types';
import type { Brain } from '@mosaic/brain';
import { AgentService } from '../agent/agent.service.js';
import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js';
@@ -8,6 +9,7 @@ import { SystemOverrideService } from '../preferences/system-override.service.js
import { ReloadService } from '../reload/reload.service.js';
import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandRegistryService } from './command-registry.service.js';
import { BRAIN } from '../brain/brain.tokens.js';
@Injectable()
export class CommandExecutorService {
@@ -19,6 +21,7 @@ export class CommandExecutorService {
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
private readonly reloadService: ReloadService | null,
@@ -87,7 +90,7 @@ export class CommandExecutorService {
};
}
case 'agent':
return await this.handleAgent(args ?? null, conversationId);
return await this.handleAgent(args ?? null, conversationId, userId);
case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission':
@@ -239,12 +242,14 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
userId: string,
): Promise<SlashCommandResultPayload> {
if (!args) {
return {
command: 'agent',
success: true,
message: 'Usage: /agent <agent-id> to switch, or /agent list to see available agents.',
message:
'Usage: /agent <agent-id> | /agent list | /agent new <name> to create a new agent.',
conversationId,
};
}
@@ -258,13 +263,101 @@ export class CommandExecutorService {
};
}
// Switch agent — stub for now (full implementation in P8-015)
return {
command: 'agent',
success: true,
message: `Agent switch to "${args}" requested. Restart conversation to apply.`,
conversationId,
};
// M5-006: /agent new <name> — create a new agent config via brain.agents.create()
if (args.startsWith('new')) {
const namePart = args.slice(3).trim();
if (!namePart) {
return {
command: 'agent',
success: false,
message: 'Usage: /agent new <name> — provide a name for the new agent.',
conversationId,
};
}
try {
const defaultProvider = process.env['DEFAULT_PROVIDER'] ?? 'anthropic';
const defaultModel = process.env['DEFAULT_MODEL'] ?? 'claude-sonnet-4-5-20251001';
const newAgent = await this.brain.agents.create({
name: namePart,
provider: defaultProvider,
model: defaultModel,
status: 'idle',
ownerId: userId,
isSystem: false,
});
this.logger.log(`Created new agent "${newAgent.name}" (${newAgent.id}) for user ${userId}`);
return {
command: 'agent',
success: true,
message: `Agent "${newAgent.name}" created with ID: ${newAgent.id}. Configure it via the web dashboard.`,
conversationId,
data: { agentId: newAgent.id, agentName: newAgent.name },
};
} catch (err) {
this.logger.error(`Failed to create agent: ${err}`);
return {
command: 'agent',
success: false,
message: `Failed to create agent: ${String(err)}`,
conversationId,
};
}
}
// M5-003: Look up agent by name (or ID) and apply to session mid-conversation
const agentName = args.trim();
try {
// Try lookup by name first; fall back to ID-based lookup
let agentConfig = await this.brain.agents.findByName(agentName);
if (!agentConfig) {
// Try by ID (UUID-style input)
agentConfig = await this.brain.agents.findById(agentName);
}
if (!agentConfig) {
return {
command: 'agent',
success: false,
message: `Agent "${agentName}" not found. Use /agent list to see available agents.`,
conversationId,
};
}
// Apply the agent config to the live session and emit session:info (M5-003)
this.agentService.applyAgentConfig(
conversationId,
agentConfig.id,
agentConfig.name,
agentConfig.model ?? undefined,
);
// Broadcast updated session:info so TUI TopBar reflects new agent/model
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
);
return {
command: 'agent',
success: true,
message: `Switched to agent "${agentConfig.name}". System prompt and tools applied. Model: ${agentConfig.model ?? 'default'}.`,
conversationId,
data: { agentId: agentConfig.id, agentName: agentConfig.name, model: agentConfig.model },
};
} catch (err) {
this.logger.error(`Failed to switch agent "${agentName}": ${err}`);
return {
command: 'agent',
success: false,
message: `Failed to switch agent: ${String(err)}`,
conversationId,
};
}
}
private async handleProvider(