Compare commits

..

1 Commits

Author SHA1 Message Date
1de3e5da2b feat(ms22-p2): add agent selector UI in WebUI
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add AgentSelector component with Jarvis/Builder/Medic options
- Add agents.ts API client for agent CRUD operations
- Update chat.ts to pass agent parameter in stream requests
- Update useChat hook to accept and pass agent parameter
- Integrate AgentSelector into Chat.tsx with state management

Task: MS22-P2-008

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:23:32 -06:00
12 changed files with 68 additions and 962 deletions

View File

@@ -343,11 +343,6 @@ RATE_LIMIT_STORAGE=redis
# DISCORD_CONTROL_CHANNEL_ID=channel-id-for-commands
# DISCORD_WORKSPACE_ID=your-workspace-uuid
#
# Agent channel routing: Maps Discord channels to specific agents.
# Format: <channelId>:<agentName>,<channelId>:<agentName>
# Example: 123456789:jarvis,987654321:builder
# DISCORD_AGENT_CHANNELS=
#
# SECURITY: DISCORD_WORKSPACE_ID must be a valid workspace UUID from your database.
# All Discord commands will execute within this workspace context for proper
# multi-tenant isolation. Each Discord bot instance should be configured for

View File

@@ -2,10 +2,9 @@ import { Module } from "@nestjs/common";
import { AgentTemplateService } from "./agent-template.service";
import { AgentTemplateController } from "./agent-template.controller";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
imports: [PrismaModule],
controllers: [AgentTemplateController],
providers: [AgentTemplateService],
exports: [AgentTemplateService],

View File

@@ -5,7 +5,6 @@ import { MatrixService } from "./matrix/matrix.service";
import { StitcherService } from "../stitcher/stitcher.service";
import { PrismaService } from "../prisma/prisma.service";
import { BullMqService } from "../bullmq/bullmq.service";
import { ChatProxyService } from "../chat-proxy/chat-proxy.service";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
@@ -90,7 +89,6 @@ interface SavedEnvVars {
MATRIX_CONTROL_ROOM_ID?: string;
MATRIX_WORKSPACE_ID?: string;
ENCRYPTION_KEY?: string;
MOSAIC_SECRET_KEY?: string;
}
describe("BridgeModule", () => {
@@ -108,7 +106,6 @@ describe("BridgeModule", () => {
MATRIX_CONTROL_ROOM_ID: process.env.MATRIX_CONTROL_ROOM_ID,
MATRIX_WORKSPACE_ID: process.env.MATRIX_WORKSPACE_ID,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
MOSAIC_SECRET_KEY: process.env.MOSAIC_SECRET_KEY,
};
// Clear all bridge env vars
@@ -123,8 +120,6 @@ describe("BridgeModule", () => {
// Set encryption key (needed by StitcherService)
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Set MOSAIC_SECRET_KEY (needed by CryptoService via ChatProxyModule)
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
// Clear ready callbacks
mockReadyCallbacks.length = 0;
@@ -154,10 +149,6 @@ describe("BridgeModule", () => {
.useValue({})
.overrideProvider(BullMqService)
.useValue({})
.overrideProvider(ChatProxyService)
.useValue({
proxyChat: vi.fn().mockResolvedValue(new Response()),
})
.compile();
}

View File

@@ -5,8 +5,6 @@ import { MatrixRoomService } from "./matrix/matrix-room.service";
import { MatrixStreamingService } from "./matrix/matrix-streaming.service";
import { CommandParserService } from "./parser/command-parser.service";
import { StitcherModule } from "../stitcher/stitcher.module";
import { ChatProxyModule } from "../chat-proxy/chat-proxy.module";
import { PrismaModule } from "../prisma/prisma.module";
import { CHAT_PROVIDERS } from "./bridge.constants";
import type { IChatProvider } from "./interfaces";
@@ -30,7 +28,7 @@ const logger = new Logger("BridgeModule");
* MatrixRoomService handles workspace-to-Matrix-room mapping.
*/
@Module({
imports: [StitcherModule, ChatProxyModule, PrismaModule],
imports: [StitcherModule],
providers: [
CommandParserService,
MatrixRoomService,

View File

@@ -1,8 +1,6 @@
import { Test, TestingModule } from "@nestjs/testing";
import { DiscordService } from "./discord.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import { PrismaService } from "../../prisma/prisma.service";
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage, ChatCommand } from "../interfaces";
@@ -63,8 +61,6 @@ vi.mock("discord.js", () => {
describe("DiscordService", () => {
let service: DiscordService;
let stitcherService: StitcherService;
let chatProxyService: ChatProxyService;
let prismaService: PrismaService;
const mockStitcherService = {
dispatchJob: vi.fn().mockResolvedValue({
@@ -75,29 +71,12 @@ describe("DiscordService", () => {
trackJobEvent: vi.fn().mockResolvedValue(undefined),
};
const mockChatProxyService = {
proxyChat: vi.fn().mockResolvedValue(
new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n', {
headers: { "Content-Type": "text/event-stream" },
})
),
};
const mockPrismaService = {
workspace: {
findUnique: vi.fn().mockResolvedValue({
ownerId: "owner-user-id",
}),
},
};
beforeEach(async () => {
// Set environment variables for testing
process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
// Clear callbacks
mockReadyCallbacks.length = 0;
@@ -110,21 +89,11 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<DiscordService>(DiscordService);
stitcherService = module.get<StitcherService>(StitcherService);
chatProxyService = module.get<ChatProxyService>(ChatProxyService);
prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks
vi.clearAllMocks();
@@ -480,14 +449,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -509,14 +470,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -539,14 +492,6 @@ describe("DiscordService", () => {
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
@@ -709,150 +654,4 @@ describe("DiscordService", () => {
expect(loggedError.statusCode).toBe(408);
});
});
describe("Agent Channel Routing", () => {
it("should load agent channel mappings from environment", () => {
// The service should have loaded the agent channels from DISCORD_AGENT_CHANNELS
expect((service as any).agentChannels.size).toBe(2);
expect((service as any).agentChannels.get("jarvis-channel")).toBe("jarvis");
expect((service as any).agentChannels.get("builder-channel")).toBe("builder");
});
it("should handle empty agent channels config", async () => {
delete process.env.DISCORD_AGENT_CHANNELS;
const module: TestingModule = await Test.createTestingModule({
providers: [
DiscordService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: ChatProxyService,
useValue: mockChatProxyService,
},
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
const newService = module.get<DiscordService>(DiscordService);
expect((newService as any).agentChannels.size).toBe(0);
// Restore for other tests
process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
});
it("should route messages in agent channels to ChatProxyService", async () => {
const mockChannel = {
send: vi.fn().mockResolvedValue({}),
isTextBased: () => true,
sendTyping: vi.fn(),
};
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
// Create a mock streaming response
const mockStreamResponse = new Response(
'data: {"choices":[{"delta":{"content":"Test response"}}]}\n\ndata: [DONE]\n\n',
{ headers: { "Content-Type": "text/event-stream" } }
);
mockChatProxyService.proxyChat.mockResolvedValue(mockStreamResponse);
await service.connect();
// Simulate a message in the jarvis channel
const message: ChatMessage = {
id: "msg-agent-1",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: "Hello Jarvis!",
timestamp: new Date(),
};
// Call handleAgentChat directly
await (service as any).handleAgentChat(message, "jarvis");
// Verify ChatProxyService was called with workspace owner's ID and agent name
expect(mockChatProxyService.proxyChat).toHaveBeenCalledWith(
"owner-user-id",
[{ role: "user", content: "Hello Jarvis!" }],
undefined,
"jarvis"
);
// Verify response was sent to channel
expect(mockChannel.send).toHaveBeenCalled();
});
it("should not route empty messages", async () => {
const message: ChatMessage = {
id: "msg-empty",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: " ",
timestamp: new Date(),
};
await (service as any).handleAgentChat(message, "jarvis");
expect(mockChatProxyService.proxyChat).not.toHaveBeenCalled();
});
it("should handle ChatProxyService errors gracefully", async () => {
const mockChannel = {
send: vi.fn().mockResolvedValue({}),
isTextBased: () => true,
sendTyping: vi.fn(),
};
(mockClient.channels.fetch as any).mockResolvedValue(mockChannel);
mockChatProxyService.proxyChat.mockRejectedValue(new Error("Agent not found"));
await service.connect();
const message: ChatMessage = {
id: "msg-error",
channelId: "jarvis-channel",
authorId: "user-1",
authorName: "TestUser",
content: "Hello",
timestamp: new Date(),
};
await (service as any).handleAgentChat(message, "jarvis");
// Should send error message to channel
expect(mockChannel.send).toHaveBeenCalledWith(
expect.stringContaining("Failed to get response from jarvis")
);
});
it("should split long messages for Discord", () => {
const longContent = "A".repeat(5000);
const chunks = (service as any).splitMessageForDiscord(longContent);
// Should split into chunks of 2000 or less
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(2000);
}
// Reassembled content should match original
expect(chunks.join("")).toBe(longContent.trim());
});
it("should prefer paragraph breaks when splitting messages", () => {
const content = "A".repeat(1500) + "\n\n" + "B".repeat(1500);
const chunks = (service as any).splitMessageForDiscord(content);
expect(chunks.length).toBe(2);
expect(chunks[0]).toContain("A");
expect(chunks[1]).toContain("B");
});
});
});

View File

@@ -1,8 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { Client, Events, GatewayIntentBits, TextChannel, ThreadChannel } from "discord.js";
import { StitcherService } from "../../stitcher/stitcher.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import { PrismaService } from "../../prisma/prisma.service";
import { sanitizeForLogging } from "../../common/utils";
import type {
IChatProvider,
@@ -19,7 +17,6 @@ import type {
* - Connect to Discord via bot token
* - Listen for commands in designated channels
* - Forward commands to stitcher
* - Route messages in agent channels to specific agents via ChatProxyService
* - Receive status updates from herald
* - Post updates to threads
*/
@@ -31,21 +28,12 @@ export class DiscordService implements IChatProvider {
private readonly botToken: string;
private readonly controlChannelId: string;
private readonly workspaceId: string;
private readonly agentChannels = new Map<string, string>();
private workspaceOwnerId: string | null = null;
constructor(
private readonly stitcherService: StitcherService,
private readonly chatProxyService: ChatProxyService,
private readonly prisma: PrismaService
) {
constructor(private readonly stitcherService: StitcherService) {
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
// Load agent channel mappings from environment
this.loadAgentChannels();
// Initialize Discord client with required intents
this.client = new Client({
intents: [
@@ -58,51 +46,6 @@ export class DiscordService implements IChatProvider {
this.setupEventHandlers();
}
/**
* Load agent channel mappings from environment variables.
* Format: DISCORD_AGENT_CHANNELS=<channelId>:<agentName>,<channelId>:<agentName>
* Example: DISCORD_AGENT_CHANNELS=123456:jarvis,789012:builder
*/
private loadAgentChannels(): void {
const channelsConfig = process.env.DISCORD_AGENT_CHANNELS ?? "";
if (!channelsConfig) {
this.logger.debug("No agent channels configured (DISCORD_AGENT_CHANNELS not set)");
return;
}
const channels = channelsConfig.split(",").map((pair) => pair.trim());
for (const channel of channels) {
const [channelId, agentName] = channel.split(":");
if (channelId && agentName) {
this.agentChannels.set(channelId.trim(), agentName.trim());
this.logger.log(`Agent channel mapped: ${channelId.trim()}${agentName.trim()}`);
}
}
}
/**
* Get the workspace owner's user ID for chat proxy routing.
* Caches the result after first lookup.
*/
private async getWorkspaceOwnerId(): Promise<string> {
if (this.workspaceOwnerId) {
return this.workspaceOwnerId;
}
const workspace = await this.prisma.workspace.findUnique({
where: { id: this.workspaceId },
select: { ownerId: true },
});
if (!workspace) {
throw new Error(`Workspace not found: ${this.workspaceId}`);
}
this.workspaceOwnerId = workspace.ownerId;
this.logger.debug(`Workspace owner resolved: ${workspace.ownerId}`);
return workspace.ownerId;
}
/**
* Setup event handlers for Discord client
*/
@@ -117,6 +60,9 @@ export class DiscordService implements IChatProvider {
// Ignore bot messages
if (message.author.bot) return;
// Check if message is in control channel
if (message.channelId !== this.controlChannelId) return;
// Parse message into ChatMessage format
const chatMessage: ChatMessage = {
id: message.id,
@@ -128,16 +74,6 @@ export class DiscordService implements IChatProvider {
...(message.channel.isThread() && { threadId: message.channelId }),
};
// Check if message is in an agent channel
const agentName = this.agentChannels.get(message.channelId);
if (agentName) {
void this.handleAgentChat(chatMessage, agentName);
return;
}
// Check if message is in control channel for commands
if (message.channelId !== this.controlChannelId) return;
// Parse command
const command = this.parseCommand(chatMessage);
if (command) {
@@ -458,150 +394,4 @@ export class DiscordService implements IChatProvider {
await this.sendMessage(message.channelId, helpMessage);
}
/**
* Handle agent chat - Route message to specific agent via ChatProxyService
* Messages in agent channels are sent directly to the agent without requiring @mosaic prefix.
*/
private async handleAgentChat(message: ChatMessage, agentName: string): Promise<void> {
this.logger.log(
`Routing message from ${message.authorName} to agent "${agentName}" in channel ${message.channelId}`
);
// Ignore empty messages
if (!message.content.trim()) {
return;
}
try {
// Get workspace owner ID for routing
const userId = await this.getWorkspaceOwnerId();
// Build message history (just the user's message for now)
const messages = [{ role: "user" as const, content: message.content }];
// Send typing indicator while waiting for response
const channel = await this.client.channels.fetch(message.channelId);
if (channel?.isTextBased()) {
void (channel as TextChannel).sendTyping();
}
// Proxy to agent
const response = await this.chatProxyService.proxyChat(
userId,
messages,
undefined,
agentName
);
// Stream the response to channel
await this.streamResponseToChannel(message.channelId, response);
this.logger.debug(`Agent "${agentName}" response sent to channel ${message.channelId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to route message to agent "${agentName}": ${errorMessage}`);
await this.sendMessage(
message.channelId,
`Failed to get response from ${agentName}. Please try again later.`
);
}
}
/**
* Stream SSE response from chat proxy and send to Discord channel.
* Collects the full response and sends as a single message for reliability.
*/
private async streamResponseToChannel(channelId: string, response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let fullContent = "";
let buffer = "";
try {
let readResult = await reader.read();
while (!readResult.done) {
const { value } = readResult;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as {
choices?: { delta?: { content?: string } }[];
};
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
fullContent += content;
}
} catch {
// Skip invalid JSON
}
}
}
readResult = await reader.read();
}
// Send the full response to Discord
if (fullContent.trim()) {
// Discord has a 2000 character limit, split if needed
const chunks = this.splitMessageForDiscord(fullContent);
for (const chunk of chunks) {
await this.sendMessage(channelId, chunk);
}
}
return fullContent;
} finally {
reader.releaseLock();
}
}
/**
* Split a message into chunks that fit within Discord's 2000 character limit.
* Tries to split on paragraph or sentence boundaries when possible.
*/
private splitMessageForDiscord(content: string, maxLength = 2000): string[] {
if (content.length <= maxLength) {
return [content];
}
const chunks: string[] = [];
let remaining = content;
while (remaining.length > maxLength) {
// Try to find a good break point
let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf("\n", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf(". ", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = remaining.lastIndexOf(" ", maxLength);
}
if (breakPoint < maxLength * 0.5) {
breakPoint = maxLength - 1;
}
chunks.push(remaining.slice(0, breakPoint + 1).trim());
remaining = remaining.slice(breakPoint + 1).trim();
}
if (remaining.length > 0) {
chunks.push(remaining);
}
return chunks;
}
}

View File

@@ -28,7 +28,6 @@ import { StitcherService } from "../../stitcher/stitcher.service";
import { HeraldService } from "../../herald/herald.service";
import { PrismaService } from "../../prisma/prisma.service";
import { BullMqService } from "../../bullmq/bullmq.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import type { IChatProvider } from "../interfaces";
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";
@@ -193,7 +192,6 @@ function setDiscordEnv(): void {
function setEncryptionKey(): void {
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
}
/**
@@ -207,10 +205,6 @@ async function compileBridgeModule(): Promise<TestingModule> {
.useValue({})
.overrideProvider(BullMqService)
.useValue({})
.overrideProvider(ChatProxyService)
.useValue({
proxyChat: vi.fn().mockResolvedValue(new Response()),
})
.compile();
}

View File

@@ -1,8 +1,4 @@
import {
ServiceUnavailableException,
NotFoundException,
BadGatewayException,
} from "@nestjs/common";
import { ServiceUnavailableException } from "@nestjs/common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatProxyService } from "./chat-proxy.service";
@@ -13,9 +9,6 @@ describe("ChatProxyService", () => {
userAgentConfig: {
findUnique: vi.fn(),
},
userAgent: {
findUnique: vi.fn(),
},
};
const containerLifecycle = {
@@ -23,17 +16,13 @@ describe("ChatProxyService", () => {
touch: vi.fn(),
};
const config = {
get: vi.fn(),
};
let service: ChatProxyService;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
service = new ChatProxyService(prisma as never, containerLifecycle as never);
});
afterEach(() => {
@@ -116,135 +105,4 @@ describe("ChatProxyService", () => {
);
});
});
describe("proxyChat with agent routing", () => {
it("includes agent config when agentName is specified", async () => {
const mockAgent = {
name: "jarvis",
displayName: "Jarvis",
personality: "Capable, direct, proactive.",
primaryModel: "opus",
isActive: true,
};
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello Jarvis" }];
await service.proxyChat(userId, messages, undefined, "jarvis");
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody).toEqual({
messages,
model: "opus",
stream: true,
agent: "jarvis",
agent_personality: "Capable, direct, proactive.",
});
});
it("throws NotFoundException when agent not found", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(null);
const messages = [{ role: "user", content: "Hello" }];
await expect(service.proxyChat(userId, messages, undefined, "nonexistent")).rejects.toThrow(
NotFoundException
);
});
it("throws NotFoundException when agent is not active", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue({
name: "inactive-agent",
displayName: "Inactive",
personality: "...",
primaryModel: null,
isActive: false,
});
const messages = [{ role: "user", content: "Hello" }];
await expect(
service.proxyChat(userId, messages, undefined, "inactive-agent")
).rejects.toThrow(NotFoundException);
});
it("falls back to default model when agent has no primaryModel", async () => {
const mockAgent = {
name: "jarvis",
displayName: "Jarvis",
personality: "Capable, direct, proactive.",
primaryModel: null,
isActive: true,
};
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
prisma.userAgent.findUnique.mockResolvedValue(mockAgent);
prisma.userAgentConfig.findUnique.mockResolvedValue(null);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello" }];
await service.proxyChat(userId, messages, undefined, "jarvis");
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody.model).toBe("openclaw:default");
});
});
describe("proxyGuestChat", () => {
it("uses environment variables for guest LLM configuration", async () => {
config.get.mockImplementation((key: string) => {
if (key === "GUEST_LLM_URL") return "http://10.1.1.42:11434/v1";
if (key === "GUEST_LLM_MODEL") return "llama3.2";
return undefined;
});
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello" }];
await service.proxyGuestChat(messages);
expect(fetchMock).toHaveBeenCalledWith(
"http://10.1.1.42:11434/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody.model).toBe("llama3.2");
});
it("throws BadGatewayException on guest LLM errors", async () => {
config.get.mockReturnValue(undefined);
fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 }));
const messages = [{ role: "user", content: "Hello" }];
await expect(service.proxyGuestChat(messages)).rejects.toThrow(BadGatewayException);
});
});
});

View File

@@ -1,300 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { UserAgentService } from "./user-agent.service";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException, ConflictException, ForbiddenException } from "@nestjs/common";
describe("UserAgentService", () => {
let service: UserAgentService;
let prisma: PrismaService;
const mockPrismaService = {
userAgent: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
agentTemplate: {
findUnique: vi.fn(),
},
};
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
const mockAgentId = "550e8400-e29b-41d4-a716-446655440002";
const mockTemplateId = "550e8400-e29b-41d4-a716-446655440003";
const mockAgent = {
id: mockAgentId,
userId: mockUserId,
templateId: null,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
primaryModel: "opus",
fallbackModels: ["sonnet"],
toolPermissions: ["all"],
discordChannel: "jarvis",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockTemplate = {
id: mockTemplateId,
name: "builder",
displayName: "Builder",
role: "coding",
personality: "Focused, thorough.",
primaryModel: "codex",
fallbackModels: ["sonnet"],
toolPermissions: ["exec", "read", "write"],
discordChannel: "builder",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserAgentService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<UserAgentService>(UserAgentService);
prisma = module.get<PrismaService>(PrismaService);
vi.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("findAll", () => {
it("should return all agents for a user", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
const result = await service.findAll(mockUserId);
expect(result).toEqual([mockAgent]);
expect(mockPrismaService.userAgent.findMany).toHaveBeenCalledWith({
where: { userId: mockUserId },
orderBy: { createdAt: "asc" },
});
});
it("should return empty array if no agents", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([]);
const result = await service.findAll(mockUserId);
expect(result).toEqual([]);
});
});
describe("findOne", () => {
it("should return an agent by id", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.findOne(mockUserId, mockAgentId);
expect(result).toEqual(mockAgent);
});
it("should throw NotFoundException if agent not found", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(NotFoundException);
});
it("should throw ForbiddenException if agent belongs to different user", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue({
...mockAgent,
userId: "different-user-id",
});
await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(ForbiddenException);
});
});
describe("findByName", () => {
it("should return an agent by name", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.findByName(mockUserId, "jarvis");
expect(result).toEqual(mockAgent);
expect(mockPrismaService.userAgent.findUnique).toHaveBeenCalledWith({
where: { userId_name: { userId: mockUserId, name: "jarvis" } },
});
});
it("should throw NotFoundException if agent not found", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
await expect(service.findByName(mockUserId, "nonexistent")).rejects.toThrow(
NotFoundException
);
});
});
describe("create", () => {
it("should create a new agent", async () => {
const createDto = {
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.userAgent.create.mockResolvedValue(mockAgent);
const result = await service.create(mockUserId, createDto);
expect(result).toEqual(mockAgent);
});
it("should throw ConflictException if agent name already exists", async () => {
const createDto = {
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
personality: "Capable, direct, proactive.",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
await expect(service.create(mockUserId, createDto)).rejects.toThrow(ConflictException);
});
it("should throw NotFoundException if templateId is invalid", async () => {
const createDto = {
name: "custom",
displayName: "Custom",
role: "custom",
personality: "Custom agent",
templateId: "nonexistent-template",
};
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
await expect(service.create(mockUserId, createDto)).rejects.toThrow(NotFoundException);
});
});
describe("createFromTemplate", () => {
it("should create an agent from a template", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrismaService.userAgent.findUnique.mockResolvedValue(null);
mockPrismaService.userAgent.create.mockResolvedValue({
...mockAgent,
templateId: mockTemplateId,
name: mockTemplate.name,
displayName: mockTemplate.displayName,
role: mockTemplate.role,
});
const result = await service.createFromTemplate(mockUserId, mockTemplateId);
expect(result.name).toBe(mockTemplate.name);
expect(result.displayName).toBe(mockTemplate.displayName);
});
it("should throw NotFoundException if template not found", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null);
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
NotFoundException
);
});
it("should throw ConflictException if agent name already exists", async () => {
mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate);
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow(
ConflictException
);
});
});
describe("update", () => {
it("should update an agent", async () => {
const updateDto = { displayName: "Updated Jarvis" };
const updatedAgent = { ...mockAgent, ...updateDto };
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
mockPrismaService.userAgent.update.mockResolvedValue(updatedAgent);
const result = await service.update(mockUserId, mockAgentId, updateDto);
expect(result.displayName).toBe("Updated Jarvis");
});
it("should throw ConflictException if new name already exists", async () => {
const updateDto = { name: "existing-name" };
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
// Second call checks for existing name
mockPrismaService.userAgent.findUnique.mockResolvedValue({ ...mockAgent, id: "other-id" });
await expect(service.update(mockUserId, mockAgentId, updateDto)).rejects.toThrow(
ConflictException
);
});
});
describe("remove", () => {
it("should delete an agent", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
mockPrismaService.userAgent.delete.mockResolvedValue(mockAgent);
const result = await service.remove(mockUserId, mockAgentId);
expect(result).toEqual(mockAgent);
});
});
describe("getStatus", () => {
it("should return agent status", async () => {
mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent);
const result = await service.getStatus(mockUserId, mockAgentId);
expect(result).toEqual({
id: mockAgentId,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
isActive: true,
});
});
});
describe("getAllStatuses", () => {
it("should return all agent statuses", async () => {
mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]);
const result = await service.getAllStatuses(mockUserId);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: mockAgentId,
name: "jarvis",
displayName: "Jarvis",
role: "orchestrator",
isActive: true,
});
});
});
});

View File

@@ -8,64 +8,61 @@
**ID:** ms22-p2-named-agent-fleet-20260304
**Statement:** Implement named agent fleet (jarvis, builder, medic) with per-agent personalities, model assignments, Discord channel routing, and WebUI selector.
**PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md`
**Phase:** Completion
**Status:** completed
**Last Updated:** 2026-03-05
**Phase:** Execution
**Status:** in-progress
**Last Updated:** 2026-03-04
## Success Criteria
1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
3. User agent CRUD endpoints allow per-user agent customization
4. Chat proxy routes messages to correct agent by name
5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
6. WebUI shows agent selector and connects to correct agent
7. All CI gates green
1. AgentTemplate and UserAgent tables exist and are seeded with jarvis/builder/medic
2. Admin CRUD endpoints at `/admin/agent-templates` work and are guarded
3. User agent CRUD endpoints allow per-user agent customization
4. Chat proxy routes messages to correct agent by name
5. Discord channel → agent routing maps #jarvis/#builder/#medic-alerts
6. WebUI shows agent selector and connects to correct agent
7. All CI gates green
## Milestones
| # | ID | Name | Status | Tasks | Notes |
| --- | ------------- | ------------- | ------- | ---------------------- | --------------------------- |
| --- | ------------- | ------------- | ---------- | -------------- | --------------------- |
| 1 | schema-seed | Schema+Seed | ✅ done | P2-001, P2-002 | PRs #675, #677 merged |
| 2 | admin-crud | Admin CRUD | ✅ done | P2-003 | PR #678 merged |
| 3 | user-crud | User CRUD | ✅ done | P2-004 | PR #682 merged |
| 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged |
| 5 | discord-ui | Discord+UI | ✅ done | P2-007, P2-008, P2-009 | PRs #685, #687, #688 merged |
| 6 | verification | Verification | ✅ done | P2-010 | All CI gates green |
| 4 | agent-routing | Agent Routing | ⬜ pending | P2-005, P2-006 | Depends on M3 |
| 5 | discord-ui | Discord+UI | ⬜ pending | P2-007, P2-008 | Depends on M4 |
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate |
## Task Summary
See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
| Task | Status | PR | Notes |
| ----------------------- | ------- | ---- | ------------------------------ |
| ----------------------- | -------------- | ---- | ------------------------------ |
| P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent |
| P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates |
| P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates |
| P2-004 User CRUD | ✅ done | #682 | /api/agents |
| P2-005 Status endpoints | ✅ done | #684 | Agent status API |
| P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy |
| P2-007 Discord routing | ✅ done | #688 | Channel → agent routing |
| P2-008 WebUI selector | ✅ done | #685 | AgentSelector component |
| P2-009 Unit tests | ✅ done | #687 | Agent services tests |
| P2-010 E2E verification | ✅ done | — | 3547 tests pass, CI green |
| P2-005 Status endpoints | ⬜ not-started | — | |
| P2-006 Chat routing | ⬜ not-started | — | |
| P2-007 Discord routing | ⬜ not-started | — | |
| P2-008 WebUI selector | ⬜ not-started | — | |
| P2-009 Unit tests | ⬜ not-started | — | |
| P2-010 E2E verification | ⬜ not-started | — | |
## Token Budget
| Phase | Est | Used |
| ----------------- | -------- | -------- |
| Schema+Seed+CRUD | 30K | ~15K |
| ----------------- | -------- | -------------------- |
| Schema+Seed+CRUD | 30K | ~15K (done directly) |
| User CRUD+Routing | 40K | ~25K |
| Discord+UI | 30K | ~24K |
| Verification | 10K | ~5K |
| **Total** | **110K** | **~69K** |
| Discord+UI | 30K | |
| Verification | 10K | |
| **Total** | **110K** | **~40K** |
## Session Log
| Date | Work Done |
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
| 2026-03-05 | Session 5: Completed P2-010 E2E verification. All 10 tasks done. Mission complete. |
| 2026-03-05 | Session 4: Completed P2-007 (Discord routing) PR #688. Milestone 5 complete. 9/10 tasks done, only E2E remains. |
| 2026-03-05 | Session 3: Completed P2-008 (WebUI agent selector) PR #685. Milestones 1-4 + P2-008 complete (3 tasks remaining). |
| ---------- | --------------------------------------------------------------------------------------------------------- |
| 2026-03-04 | Session 2: Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete (4/6 remaining). |
| 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |

View File

@@ -95,14 +95,14 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
| ----------- | ------ | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | --------------- |
| ----------- | ----------- | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ---------- | ---------- | ---------- | ---------- | -------------- |
| MS22-P2-001 | done | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 3K | PR #675 merged |
| MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged |
| MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged |
| MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged |
| MS22-P2-005 | done | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-003 | P2-008 | orchestrator | 2026-03-04 | 2026-03-04 | 10K | 5K | PR #684 merged |
| MS22-P2-006 | done | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #684 merged |
| MS22-P2-007 | done | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-010 | orchestrator | 2026-03-05 | 2026-03-05 | 15K | 8K | PR #688 |
| MS22-P2-008 | done | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #685 merged |
| MS22-P2-009 | done | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-006 | P2-010 | orchestrator | 2026-03-04 | 2026-03-05 | 15K | 8K | PR #687 merged |
| MS22-P2-010 | done | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | orchestrator | 2026-03-05 | 2026-03-05 | 10K | 5K | All gates green |
| MS22-P2-005 | in-progress | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-status | P2-003 | P2-008 | orchestrator | 2026-03-04 | | 10K | | |
| MS22-P2-006 | not-started | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | — | — | — | 15K | | |
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | | |
| MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | | |
| MS22-P2-009 | not-started | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-007 | P2-010 | — | — | — | 15K | | |
| MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | | |

View File

@@ -14,25 +14,10 @@
## Session Log
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | ---------------------- | --------------------------------------------------------------------------------------------- |
| 5 | 2026-03-05 | M6 | P2-010 done | E2E verification: 3547 tests pass, all CI gates green. Mission complete. |
| 4 | 2026-03-05 | M5+M6 | P2-007 done | Discord channel→agent routing. Fixed lint/type errors. PR #688 merged. 9/10 tasks done. |
| 3 | 2026-03-05 | M4+M5 | P2-008 done | Fixed corrupted AgentSelector.tsx, integrated into Chat.tsx. PR #685 merged. 8/10 tasks done. |
| ------- | ---------- | --------- | ---------------------- | ------------------------------------------------------------------------------ |
| 2 | 2026-03-04 | M1+M2+M3 | P2-004 done | Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete. |
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete |
## Mission Complete
All 10 tasks completed. Success criteria verified:
1. ✅ AgentTemplate and UserAgent tables in Prisma schema
2. ✅ Admin CRUD at /admin/agent-templates
3. ✅ User CRUD at /api/agents
4. ✅ Chat proxy routes by agent name
5. ✅ Discord channel routing via DISCORD_AGENT_CHANNELS
6. ✅ WebUI AgentSelector component
7. ✅ 3547 tests passing, CI green
## Open Questions
## Corrections