Compare commits

..

1 Commits

Author SHA1 Message Date
7ec1906a20 feat(ms22-p2): add AgentTemplate admin CRUD endpoints
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-04 20:27:32 -06:00
19 changed files with 42 additions and 1138 deletions

View File

@@ -49,7 +49,6 @@ import { PersonalitiesModule } from "./personalities/personalities.module";
import { WorkspacesModule } from "./workspaces/workspaces.module"; import { WorkspacesModule } from "./workspaces/workspaces.module";
import { AdminModule } from "./admin/admin.module"; import { AdminModule } from "./admin/admin.module";
import { AgentTemplateModule } from "./agent-template/agent-template.module"; import { AgentTemplateModule } from "./agent-template/agent-template.module";
import { UserAgentModule } from "./user-agent/user-agent.module";
import { TeamsModule } from "./teams/teams.module"; import { TeamsModule } from "./teams/teams.module";
import { ImportModule } from "./import/import.module"; import { ImportModule } from "./import/import.module";
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module"; import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
@@ -132,7 +131,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
WorkspacesModule, WorkspacesModule,
AdminModule, AdminModule,
AgentTemplateModule, AgentTemplateModule,
UserAgentModule,
TeamsModule, TeamsModule,
ImportModule, ImportModule,
ConversationArchiveModule, ConversationArchiveModule,

View File

@@ -99,8 +99,7 @@ export class ChatProxyController {
const upstreamResponse = await this.chatProxyService.proxyChat( const upstreamResponse = await this.chatProxyService.proxyChat(
userId, userId,
body.messages, body.messages,
abortController.signal, abortController.signal
body.agent
); );
const upstreamContentType = upstreamResponse.headers.get("content-type"); const upstreamContentType = upstreamResponse.headers.get("content-type");

View File

@@ -1,12 +1,5 @@
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export interface ChatMessage { export interface ChatMessage {
role: string; role: string;
@@ -29,8 +22,4 @@ export class ChatStreamDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => ChatMessageDto) @Type(() => ChatMessageDto)
messages!: ChatMessageDto[]; messages!: ChatMessageDto[];
@IsString({ message: "agent must be a string" })
@IsOptional()
agent?: string;
} }

View File

@@ -1,8 +1,4 @@
import { import { ServiceUnavailableException } from "@nestjs/common";
ServiceUnavailableException,
NotFoundException,
BadGatewayException,
} from "@nestjs/common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatProxyService } from "./chat-proxy.service"; import { ChatProxyService } from "./chat-proxy.service";
@@ -13,9 +9,6 @@ describe("ChatProxyService", () => {
userAgentConfig: { userAgentConfig: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
userAgent: {
findUnique: vi.fn(),
},
}; };
const containerLifecycle = { const containerLifecycle = {
@@ -23,17 +16,13 @@ describe("ChatProxyService", () => {
touch: vi.fn(), touch: vi.fn(),
}; };
const config = {
get: vi.fn(),
};
let service: ChatProxyService; let service: ChatProxyService;
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
fetchMock = vi.fn(); fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock); 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(() => { 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

@@ -2,7 +2,6 @@ import {
BadGatewayException, BadGatewayException,
Injectable, Injectable,
Logger, Logger,
NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
@@ -19,13 +18,6 @@ interface ContainerConnection {
token: string; token: string;
} }
interface AgentConfig {
name: string;
displayName: string;
personality: string;
primaryModel: string | null;
}
@Injectable() @Injectable()
export class ChatProxyService { export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name); private readonly logger = new Logger(ChatProxyService.name);
@@ -46,38 +38,21 @@ export class ChatProxyService {
async proxyChat( async proxyChat(
userId: string, userId: string,
messages: ChatMessage[], messages: ChatMessage[],
signal?: AbortSignal, signal?: AbortSignal
agentName?: string
): Promise<Response> { ): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId); const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(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 requestInit: RequestInit = { const requestInit: RequestInit = {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`, Authorization: `Bearer ${gatewayToken}`,
}, },
body: JSON.stringify(requestBody), body: JSON.stringify({
messages,
model,
stream: true,
}),
}; };
if (signal) { if (signal) {
@@ -195,32 +170,4 @@ export class ChatProxyService {
return null; 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,
};
}
} }

View File

@@ -1,43 +0,0 @@
import { IsString, IsBoolean, IsOptional, IsArray, MinLength } from "class-validator";
export class CreateUserAgentDto {
@IsString()
@MinLength(1)
templateId?: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
displayName!: string;
@IsString()
@MinLength(1)
role!: string;
@IsString()
@MinLength(1)
personality!: string;
@IsString()
@IsOptional()
primaryModel?: string;
@IsArray()
@IsOptional()
fallbackModels?: string[];
@IsArray()
@IsOptional()
toolPermissions?: string[];
@IsString()
@IsOptional()
discordChannel?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserAgentDto } from "./create-user-agent.dto";
export class UpdateUserAgentDto extends PartialType(CreateUserAgentDto) {}

View File

@@ -1,70 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from "@nestjs/common";
import { UserAgentService } from "./user-agent.service";
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
import { UpdateUserAgentDto } from "./dto/update-user-agent.dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "@mosaic/shared";
@Controller("agents")
@UseGuards(AuthGuard)
export class UserAgentController {
constructor(private readonly userAgentService: UserAgentService) {}
@Get()
findAll(@CurrentUser() user: AuthUser) {
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);
}
@Post("from-template/:templateId")
createFromTemplate(
@CurrentUser() user: AuthUser,
@Param("templateId", ParseUUIDPipe) templateId: string
) {
return this.userAgentService.createFromTemplate(user.id, templateId);
}
@Patch(":id")
update(
@CurrentUser() user: AuthUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateUserAgentDto
) {
return this.userAgentService.update(user.id, id, dto);
}
@Delete(":id")
remove(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
return this.userAgentService.remove(user.id, id);
}
}

View File

@@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { UserAgentService } from "./user-agent.service";
import { UserAgentController } from "./user-agent.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
controllers: [UserAgentController],
providers: [UserAgentService],
exports: [UserAgentService],
})
export class UserAgentModule {}

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

@@ -1,153 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from "@nestjs/common";
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) {}
async findAll(userId: string) {
return this.prisma.userAgent.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
});
}
async findOne(userId: string, id: string) {
const agent = await this.prisma.userAgent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException(`UserAgent ${id} not found`);
if (agent.userId !== userId) throw new ForbiddenException("Access denied to this agent");
return agent;
}
async findByName(userId: string, name: string) {
const agent = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name } },
});
if (!agent) throw new NotFoundException(`UserAgent "${name}" not found for user`);
return agent;
}
async create(userId: string, dto: CreateUserAgentDto) {
// Check for unique name within user scope
const existing = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: dto.name } },
});
if (existing)
throw new ConflictException(`UserAgent "${dto.name}" already exists for this user`);
// If templateId provided, verify it exists
if (dto.templateId) {
const template = await this.prisma.agentTemplate.findUnique({
where: { id: dto.templateId },
});
if (!template) throw new NotFoundException(`AgentTemplate ${dto.templateId} not found`);
}
return this.prisma.userAgent.create({
data: {
userId,
templateId: dto.templateId ?? null,
name: dto.name,
displayName: dto.displayName,
role: dto.role,
personality: dto.personality,
primaryModel: dto.primaryModel ?? null,
fallbackModels: dto.fallbackModels ?? ([] as string[]),
toolPermissions: dto.toolPermissions ?? ([] as string[]),
discordChannel: dto.discordChannel ?? null,
isActive: dto.isActive ?? true,
},
});
}
async createFromTemplate(userId: string, templateId: string) {
const template = await this.prisma.agentTemplate.findUnique({
where: { id: templateId },
});
if (!template) throw new NotFoundException(`AgentTemplate ${templateId} not found`);
// Check for unique name within user scope
const existing = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: template.name } },
});
if (existing)
throw new ConflictException(`UserAgent "${template.name}" already exists for this user`);
return this.prisma.userAgent.create({
data: {
userId,
templateId: template.id,
name: template.name,
displayName: template.displayName,
role: template.role,
personality: template.personality,
primaryModel: template.primaryModel,
fallbackModels: template.fallbackModels as string[],
toolPermissions: template.toolPermissions as string[],
discordChannel: template.discordChannel,
isActive: template.isActive,
},
});
}
async update(userId: string, id: string, dto: UpdateUserAgentDto) {
const agent = await this.findOne(userId, id);
// If name is being changed, check for uniqueness
if (dto.name && dto.name !== agent.name) {
const existing = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: dto.name } },
});
if (existing)
throw new ConflictException(`UserAgent "${dto.name}" already exists for this user`);
}
return this.prisma.userAgent.update({
where: { id },
data: dto,
});
}
async remove(userId: string, id: string) {
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,
}));
}
}

View File

@@ -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>
);
}

View File

@@ -9,7 +9,6 @@ import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList"; import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState"; import { ChatEmptyState } from "./ChatEmptyState";
import { AgentSelector } from "./AgentSelector";
import type { Message } from "@/hooks/useChat"; import type { Message } from "@/hooks/useChat";
export interface ChatRef { export interface ChatRef {
@@ -67,7 +66,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2"); const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE); const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS); const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
// Suggestion fill value: controls ChatInput's textarea content // Suggestion fill value: controls ChatInput's textarea content
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined); const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
@@ -90,7 +88,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
temperature, temperature,
maxTokens, maxTokens,
...(initialProjectId !== undefined && { projectId: initialProjectId }), ...(initialProjectId !== undefined && { projectId: initialProjectId }),
...(selectedAgent !== null && { agent: selectedAgent }),
}); });
// Read workspace ID from localStorage (set by auth-context after session check). // 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="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 <ChatInput
onSend={handleSendMessage} onSend={handleSendMessage}
disabled={isChatLoading || !user} disabled={isChatLoading || !user}

View File

@@ -27,7 +27,6 @@ export interface UseChatOptions {
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
projectId?: string | null; projectId?: string | null;
agent?: string;
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
@@ -64,7 +63,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
maxTokens, maxTokens,
systemPrompt, systemPrompt,
projectId, projectId,
agent,
onError, onError,
} = options; } = options;
@@ -79,10 +77,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const projectIdRef = useRef<string | null>(projectId ?? null); const projectIdRef = useRef<string | null>(projectId ?? null);
projectIdRef.current = 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 // Track messages in ref to prevent stale closures during rapid sends
const messagesRef = useRef<Message[]>(messages); const messagesRef = useRef<Message[]>(messages);
messagesRef.current = messages; messagesRef.current = messages;
@@ -215,7 +209,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
...(temperature !== undefined && { temperature }), ...(temperature !== undefined && { temperature }),
...(maxTokens !== undefined && { maxTokens }), ...(maxTokens !== undefined && { maxTokens }),
...(systemPrompt !== undefined && { systemPrompt }), ...(systemPrompt !== undefined && { systemPrompt }),
...(agentRef.current && { agent: agentRef.current }),
}; };
const controller = new AbortController(); const controller = new AbortController();

View File

@@ -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}`);
}

View File

@@ -18,7 +18,6 @@ export interface ChatRequest {
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
systemPrompt?: string; systemPrompt?: string;
agent?: string;
} }
export interface ChatResponse { export interface ChatResponse {
@@ -118,11 +117,7 @@ export function streamGuestChat(
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({ messages: request.messages, stream: true }),
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
signal: signal ?? null, signal: signal ?? null,
}); });
@@ -274,11 +269,7 @@ export function streamChatMessage(
"X-CSRF-Token": csrfToken, "X-CSRF-Token": csrfToken,
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({ messages: request.messages, stream: true }),
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
signal: signal ?? null, signal: signal ?? null,
}); });

View File

@@ -10,7 +10,7 @@
**PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md` **PRD:** `docs/PRD-MS22-P2-AGENT-FLEET.md`
**Phase:** Execution **Phase:** Execution
**Status:** in-progress **Status:** in-progress
**Last Updated:** 2026-03-05 **Last Updated:** 2026-03-04
## Success Criteria ## Success Criteria
@@ -28,9 +28,9 @@
| --- | ------------- | ------------- | ---------- | -------------- | --------------------- | | --- | ------------- | ------------- | ---------- | -------------- | --------------------- |
| 1 | schema-seed | Schema+Seed | ✅ done | P2-001, P2-002 | PRs #675, #677 merged | | 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 | | 2 | admin-crud | Admin CRUD | ✅ done | P2-003 | PR #678 merged |
| 3 | user-crud | User CRUD | ✅ done | P2-004 | PR #682 merged | | 3 | user-crud | User CRUD | 🔄 next | P2-004 | Depends on M2 |
| 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged | | 4 | agent-routing | Agent Routing | ⬜ pending | P2-005, P2-006 | Depends on M3 |
| 5 | discord-ui | Discord+UI | 🔄 partial | P2-007, P2-008 | P2-008 done (PR #685) | | 5 | discord-ui | Discord+UI | pending | P2-007, P2-008 | Depends on M4 |
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate | | 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate |
## Task Summary ## Task Summary
@@ -42,28 +42,26 @@ See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
| P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent | | P2-001 Schema | ✅ done | #675 | AgentTemplate + UserAgent |
| P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates | | P2-002 Seed | ✅ done | #677 | jarvis/builder/medic templates |
| P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates | | P2-003 Admin CRUD | ✅ done | #678 | /admin/agent-templates |
| P2-004 User CRUD | ✅ done | #682 | /api/agents | | P2-004 User CRUD | ⬜ not-started | — | |
| P2-005 Status endpoints | ✅ done | #684 | Agent status API | | P2-005 Status endpoints | ⬜ not-started | — | |
| P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy | | P2-006 Chat routing | ⬜ not-started | — | |
| P2-007 Discord routing | ⬜ not-started | — | | | P2-007 Discord routing | ⬜ not-started | — | |
| P2-008 WebUI selector | ✅ done | #685 | AgentSelector component | | P2-008 WebUI selector | ⬜ not-started | — | |
| P2-009 Unit tests | ⬜ not-started | — | | | P2-009 Unit tests | ⬜ not-started | — | |
| P2-010 E2E verification | ⬜ not-started | — | | | P2-010 E2E verification | ⬜ not-started | — | |
## Token Budget ## Token Budget
| Phase | Est | Used | | Phase | Est | Used |
| ----------------- | -------- | ------------------ | | ----------------- | -------- | -------------------- |
| Schema+Seed+CRUD | 30K | ~15K | | Schema+Seed+CRUD | 30K | ~10K (done directly) |
| User CRUD+Routing | 40K | ~25K | | User CRUD+Routing | 40K | |
| Discord+UI | 30K | ~15K (P2-008 done) | | Discord+UI | 30K | |
| Verification | 10K | — | | Verification | 10K | — |
| **Total** | **110K** | **~55K** | | **Total** | **110K** | **~10K** |
## Session Log ## Session Log
| Date | Work Done | | Date | Work Done |
| ---------- | ----------------------------------------------------------------------------------------------------------------- | | ---------- | ------------------------------------------------------------------ |
| 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 | | 2026-03-04 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |

View File

@@ -97,12 +97,12 @@ 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 | | 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-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-002 | not-started | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | — | — | — | 5K | | |
| 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-003 | not-started | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-001 | P2-005 | — | — | — | 15K | | |
| 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-004 | not-started | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-002,P2-003 | P2-006 | — | — | — | 15K | | |
| 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-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | | |
| 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-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-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 | 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-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-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 | — | | | MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | — | |

View File

@@ -1,24 +0,0 @@
# Mission Scratchpad — MS22-P2 Named Agent Fleet
> Append-only log. NEVER delete entries. NEVER overwrite sections.
> This is the orchestrator's working memory across sessions.
## Original Mission Prompt
```
(Paste the mission prompt here on first session)
```
## Planning Decisions
## Session Log
| Session | Date | Milestone | Tasks Done | Outcome |
| ------- | ---------- | --------- | ---------------------- | --------------------------------------------------------------------------------------------- |
| 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 |
## Open Questions
## Corrections