Compare commits
1 Commits
feat/ms22-
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c7f35f881 |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,7 @@ export class ChatProxyController {
|
||||
const upstreamResponse = await this.chatProxyService.proxyChat(
|
||||
userId,
|
||||
body.messages,
|
||||
abortController.signal,
|
||||
body.agent
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
@@ -29,8 +22,4 @@ export class ChatStreamDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages!: ChatMessageDto[];
|
||||
|
||||
@IsString({ message: "agent must be a string" })
|
||||
@IsOptional()
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -19,13 +18,6 @@ interface ContainerConnection {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface AgentConfig {
|
||||
name: string;
|
||||
displayName: string;
|
||||
personality: string;
|
||||
primaryModel: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatProxyService {
|
||||
private readonly logger = new Logger(ChatProxyService.name);
|
||||
@@ -46,38 +38,21 @@ export class ChatProxyService {
|
||||
async proxyChat(
|
||||
userId: string,
|
||||
messages: ChatMessage[],
|
||||
signal?: AbortSignal,
|
||||
agentName?: string
|
||||
signal?: AbortSignal
|
||||
): Promise<Response> {
|
||||
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
|
||||
|
||||
// Get agent config if specified
|
||||
let agentConfig: AgentConfig | null = null;
|
||||
if (agentName) {
|
||||
agentConfig = await this.getAgentConfig(userId, agentName);
|
||||
}
|
||||
|
||||
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
messages,
|
||||
model,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// Add agent config if available
|
||||
if (agentConfig) {
|
||||
requestBody.agent = agentConfig.name;
|
||||
requestBody.agent_personality = agentConfig.personality;
|
||||
}
|
||||
|
||||
const model = await this.getPreferredModel(userId);
|
||||
const requestInit: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${gatewayToken}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
model,
|
||||
stream: true,
|
||||
}),
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
@@ -195,32 +170,4 @@ export class ChatProxyService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
|
||||
const agent = await this.prisma.userAgent.findUnique({
|
||||
where: { userId_name: { userId, name: agentName } },
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
personality: true,
|
||||
primaryModel: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Agent "${agentName}" not found for user`);
|
||||
}
|
||||
|
||||
if (!agent.isActive) {
|
||||
throw new NotFoundException(`Agent "${agentName}" is not active`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: agent.name,
|
||||
displayName: agent.displayName,
|
||||
personality: agent.personality,
|
||||
primaryModel: agent.primaryModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,21 +26,11 @@ export class UserAgentController {
|
||||
return this.userAgentService.findAll(user.id);
|
||||
}
|
||||
|
||||
@Get("status")
|
||||
getAllStatuses(@CurrentUser() user: AuthUser) {
|
||||
return this.userAgentService.getAllStatuses(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.userAgentService.findOne(user.id, id);
|
||||
}
|
||||
|
||||
@Get(":id/status")
|
||||
getStatus(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.userAgentService.getStatus(user.id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
|
||||
return this.userAgentService.create(user.id, dto);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,6 @@ import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
|
||||
import { UpdateUserAgentDto } from "./dto/update-user-agent.dto";
|
||||
|
||||
export interface AgentStatusResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
containerStatus?: "running" | "stopped" | "unknown";
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserAgentService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -128,26 +119,4 @@ export class UserAgentService {
|
||||
await this.findOne(userId, id);
|
||||
return this.prisma.userAgent.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async getStatus(userId: string, id: string): Promise<AgentStatusResponse> {
|
||||
const agent = await this.findOne(userId, id);
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
displayName: agent.displayName,
|
||||
role: agent.role,
|
||||
isActive: agent.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllStatuses(userId: string): Promise<AgentStatusResponse[]> {
|
||||
const agents = await this.findAll(userId);
|
||||
return agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
displayName: agent.displayName,
|
||||
role: agent.role,
|
||||
isActive: agent.isActive,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
selectedAgent?: string | null;
|
||||
onChange?: (agent: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AGENT_CONFIG = {
|
||||
jarvis: {
|
||||
displayName: "Jarvis",
|
||||
role: "Orchestrator",
|
||||
color: "#3498db",
|
||||
},
|
||||
builder: {
|
||||
displayName: "Builder",
|
||||
role: "Coding Agent",
|
||||
color: "#3b82f6",
|
||||
},
|
||||
medic: {
|
||||
displayName: "Medic",
|
||||
role: "Health Monitor",
|
||||
color: "#10b981",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function JarvisIcon({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg
|
||||
className={`w-3 h-3 ${className ?? ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 22v-4" />
|
||||
<path d="M2 12h4M22 12h-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderIcon({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg
|
||||
className={`w-3 h-3 ${className ?? ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicIcon({ className }: { className?: string }): React.ReactElement {
|
||||
return (
|
||||
<svg
|
||||
className={`w-3 h-3 ${className ?? ""}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const AGENT_ICONS: Record<string, React.FC<{ className?: string }>> = {
|
||||
jarvis: JarvisIcon,
|
||||
builder: BuilderIcon,
|
||||
medic: MedicIcon,
|
||||
};
|
||||
|
||||
export function AgentSelector({
|
||||
selectedAgent,
|
||||
onChange,
|
||||
disabled,
|
||||
}: AgentSelectorProps): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: "rgb(var(--text-muted))" }}>
|
||||
Agent
|
||||
</span>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(AGENT_CONFIG).map(([name, config]) => {
|
||||
const Icon = AGENT_ICONS[name];
|
||||
const isSelected = selectedAgent === name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onChange?.(isSelected ? null : name)}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all text-xs ${
|
||||
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? "rgb(var(--accent-primary))"
|
||||
: "rgb(var(--border-default))",
|
||||
color: isSelected ? "rgb(var(--accent-primary))" : "rgb(var(--text-primary))",
|
||||
}}
|
||||
title={`${config.displayName} — ${config.role}`}
|
||||
>
|
||||
<span
|
||||
className="rounded-full"
|
||||
style={{
|
||||
backgroundColor: config.color,
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
}}
|
||||
/>
|
||||
{Icon && <Icon />}
|
||||
<span className="font-medium">{config.displayName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { useWorkspaceId } from "@/lib/hooks";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||
import { ChatEmptyState } from "./ChatEmptyState";
|
||||
import { AgentSelector } from "./AgentSelector";
|
||||
import type { Message } from "@/hooks/useChat";
|
||||
|
||||
export interface ChatRef {
|
||||
@@ -67,7 +66,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
||||
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
|
||||
|
||||
// Suggestion fill value: controls ChatInput's textarea content
|
||||
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
||||
@@ -90,7 +88,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
temperature,
|
||||
maxTokens,
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
...(selectedAgent !== null && { agent: selectedAgent }),
|
||||
});
|
||||
|
||||
// Read workspace ID from localStorage (set by auth-context after session check).
|
||||
@@ -378,13 +375,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||
<div className="mb-3">
|
||||
<AgentSelector
|
||||
selectedAgent={selectedAgent}
|
||||
onChange={setSelectedAgent}
|
||||
disabled={isChatLoading || isStreaming || !user}
|
||||
/>
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isChatLoading || !user}
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface UseChatOptions {
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
projectId?: string | null;
|
||||
agent?: string;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
@@ -64,7 +63,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
maxTokens,
|
||||
systemPrompt,
|
||||
projectId,
|
||||
agent,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
@@ -79,10 +77,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
const projectIdRef = useRef<string | null>(projectId ?? null);
|
||||
projectIdRef.current = projectId ?? null;
|
||||
|
||||
// Track agent in ref to prevent stale closures
|
||||
const agentRef = useRef<string | undefined>(agent);
|
||||
agentRef.current = agent;
|
||||
|
||||
// Track messages in ref to prevent stale closures during rapid sends
|
||||
const messagesRef = useRef<Message[]>(messages);
|
||||
messagesRef.current = messages;
|
||||
@@ -215,7 +209,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(maxTokens !== undefined && { maxTokens }),
|
||||
...(systemPrompt !== undefined && { systemPrompt }),
|
||||
...(agentRef.current && { agent: agentRef.current }),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Agent API client
|
||||
* Handles agent-related API interactions
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||
|
||||
export interface AgentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
containerStatus?: "running" | "stopped" | "unknown";
|
||||
}
|
||||
|
||||
export interface UserAgent {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
personality: string;
|
||||
primaryModel: string | null;
|
||||
fallbackModels: string[];
|
||||
toolPermissions: string[];
|
||||
discordChannel: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserAgentRequest {
|
||||
templateId?: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
personality: string;
|
||||
primaryModel?: string;
|
||||
fallbackModels?: string[];
|
||||
toolPermissions?: string[];
|
||||
discordChannel?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserAgentRequest {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
personality?: string;
|
||||
primaryModel?: string;
|
||||
fallbackModels?: string[];
|
||||
toolPermissions?: string[];
|
||||
discordChannel?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserAgentRequest {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
personality?: string;
|
||||
primaryModel?: string;
|
||||
fallbackModels?: string[];
|
||||
toolPermissions?: string[];
|
||||
discordChannel?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user's agents
|
||||
*/
|
||||
export async function getAgents(): Promise<UserAgent[]> {
|
||||
return apiGet<UserAgent[]>("/api/agents");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agent statuses
|
||||
*/
|
||||
export async function getAgentStatuses(): Promise<AgentStatus[]> {
|
||||
return apiGet<AgentStatus[]>("/api/agents/status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single agent by ID
|
||||
*/
|
||||
export async function getAgent(id: string): Promise<UserAgent> {
|
||||
return apiGet<UserAgent>(`/api/agents/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single agent's status
|
||||
*/
|
||||
export async function getAgentStatus(id: string): Promise<AgentStatus> {
|
||||
return apiGet<AgentStatus>(`/api/agents/${id}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom agent
|
||||
*/
|
||||
export async function createAgent(data: CreateUserAgentRequest): Promise<UserAgent> {
|
||||
return apiPost<UserAgent>("/api/agents", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent from a template
|
||||
*/
|
||||
export async function createAgentFromTemplate(templateId: string): Promise<UserAgent> {
|
||||
return apiPost<UserAgent>(`/api/agents/from-template/${templateId}`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an agent
|
||||
*/
|
||||
export async function updateAgent(id: string, data: UpdateUserAgentRequest): Promise<UserAgent> {
|
||||
return apiPatch<UserAgent>(`/api/agents/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an agent
|
||||
*/
|
||||
export async function deleteAgent(id: string): Promise<void> {
|
||||
await apiDelete(`/api/agents/${id}`);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export interface ChatRequest {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPrompt?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
@@ -118,11 +117,7 @@ export function streamGuestChat(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
messages: request.messages,
|
||||
stream: true,
|
||||
...(request.agent && { agent: request.agent }),
|
||||
}),
|
||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
||||
signal: signal ?? null,
|
||||
});
|
||||
|
||||
@@ -274,11 +269,7 @@ export function streamChatMessage(
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
messages: request.messages,
|
||||
stream: true,
|
||||
...(request.agent && { agent: request.agent }),
|
||||
}),
|
||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
||||
signal: signal ?? null,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,67 +5,64 @@
|
||||
|
||||
## Mission
|
||||
|
||||
**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
|
||||
**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:** 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 |
|
||||
| # | 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 | 🔄 in-progress | P2-004 | Depends on M2 |
|
||||
| 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 |
|
||||
| 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 | 🔄 in-progress | — | |
|
||||
| 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 |
|
||||
| User CRUD+Routing | 40K | ~25K |
|
||||
| Discord+UI | 30K | ~24K |
|
||||
| Verification | 10K | ~5K |
|
||||
| **Total** | **110K** | **~69K** |
|
||||
| Phase | Est | Used |
|
||||
| ----------------- | -------- | -------------------- |
|
||||
| Schema+Seed+CRUD | 30K | ~15K (done directly) |
|
||||
| User CRUD+Routing | 40K | — |
|
||||
| Discord+UI | 30K | — |
|
||||
| Verification | 10K | — |
|
||||
| **Total** | **110K** | **~15K** |
|
||||
|
||||
## 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 |
|
||||
| Date | Work Done |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------- |
|
||||
| 2026-03-04 | Session 2: Fixed CI security audit (multer override), merged PR #678, starting P2-004 User CRUD |
|
||||
| 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |
|
||||
|
||||
@@ -94,15 +94,15 @@ 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 |
|
||||
| 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 | in-progress | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | — | 15K | — | |
|
||||
| MS22-P2-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 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 | — | |
|
||||
|
||||
@@ -13,25 +13,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
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | ----------- | ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| 2 | 2026-03-04 | M3-UserCRUD | P2-004 in-progress | Fixed CI security audit (multer>=2.1.1), merged PR #678 (Admin CRUD), starting User CRUD |
|
||||
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user