Compare commits
4 Commits
feat/ms22-
...
test/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 156ea1438b | |||
| 0869a3dcb6 | |||
| a70f149886 | |||
| 2f1ee53c8d |
@@ -99,7 +99,8 @@ 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");
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -22,4 +29,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ServiceUnavailableException } from "@nestjs/common";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -9,6 +13,9 @@ describe("ChatProxyService", () => {
|
|||||||
userAgentConfig: {
|
userAgentConfig: {
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
},
|
},
|
||||||
|
userAgent: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerLifecycle = {
|
const containerLifecycle = {
|
||||||
@@ -16,13 +23,17 @@ 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);
|
service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -105,4 +116,135 @@ 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,6 +2,7 @@ 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";
|
||||||
@@ -18,6 +19,13 @@ 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);
|
||||||
@@ -38,21 +46,38 @@ 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({
|
body: JSON.stringify(requestBody),
|
||||||
messages,
|
|
||||||
model,
|
|
||||||
stream: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
@@ -170,4 +195,32 @@ 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ export class UserAgentController {
|
|||||||
return this.userAgentService.findAll(user.id);
|
return this.userAgentService.findAll(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("status")
|
||||||
|
getAllStatuses(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.userAgentService.getAllStatuses(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.userAgentService.findOne(user.id, id);
|
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()
|
@Post()
|
||||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
|
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
|
||||||
return this.userAgentService.create(user.id, dto);
|
return this.userAgentService.create(user.id, dto);
|
||||||
|
|||||||
300
apps/api/src/user-agent/user-agent.service.spec.ts
Normal file
300
apps/api/src/user-agent/user-agent.service.spec.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
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,6 +8,15 @@ import { PrismaService } from "../prisma/prisma.service";
|
|||||||
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
|
import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
|
||||||
import { UpdateUserAgentDto } from "./dto/update-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()
|
@Injectable()
|
||||||
export class UserAgentService {
|
export class UserAgentService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -119,4 +128,26 @@ export class UserAgentService {
|
|||||||
await this.findOne(userId, id);
|
await this.findOne(userId, id);
|
||||||
return this.prisma.userAgent.delete({ where: { 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
apps/web/src/components/chat/AgentSelector.tsx
Normal file
128
apps/web/src/components/chat/AgentSelector.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"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,6 +9,7 @@ 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 {
|
||||||
@@ -66,6 +67,7 @@ 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);
|
||||||
@@ -88,6 +90,7 @@ 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).
|
||||||
@@ -375,6 +378,13 @@ 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}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
maxTokens,
|
maxTokens,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
projectId,
|
projectId,
|
||||||
|
agent,
|
||||||
onError,
|
onError,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -77,6 +79,10 @@ 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;
|
||||||
@@ -209,6 +215,7 @@ 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();
|
||||||
|
|||||||
125
apps/web/src/lib/api/agents.ts
Normal file
125
apps/web/src/lib/api/agents.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +18,7 @@ export interface ChatRequest {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
agent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
@@ -117,7 +118,11 @@ export function streamGuestChat(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
body: JSON.stringify({
|
||||||
|
messages: request.messages,
|
||||||
|
stream: true,
|
||||||
|
...(request.agent && { agent: request.agent }),
|
||||||
|
}),
|
||||||
signal: signal ?? null,
|
signal: signal ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +274,11 @@ export function streamChatMessage(
|
|||||||
"X-CSRF-Token": csrfToken,
|
"X-CSRF-Token": csrfToken,
|
||||||
},
|
},
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
body: JSON.stringify({
|
||||||
|
messages: request.messages,
|
||||||
|
stream: true,
|
||||||
|
...(request.agent && { agent: request.agent }),
|
||||||
|
}),
|
||||||
signal: signal ?? null,
|
signal: signal ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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-04
|
**Last Updated:** 2026-03-05
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
| 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 | ✅ done | P2-004 | PR #682 merged |
|
||||||
| 4 | agent-routing | Agent Routing | ⬜ pending | P2-005, P2-006 | Depends on M3 |
|
| 4 | agent-routing | Agent Routing | ✅ done | P2-005, P2-006 | PR #684 merged |
|
||||||
| 5 | discord-ui | Discord+UI | ⬜ pending | P2-007, P2-008 | Depends on M4 |
|
| 5 | discord-ui | Discord+UI | 🔄 partial | P2-007, P2-008 | P2-008 done (PR #685) |
|
||||||
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate |
|
| 6 | verification | Verification | ⬜ pending | P2-009, P2-010 | Final gate |
|
||||||
|
|
||||||
## Task Summary
|
## Task Summary
|
||||||
@@ -43,26 +43,27 @@ See `docs/TASKS.md` — MS22 Phase 2 section for full task details.
|
|||||||
| 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 | ✅ done | #682 | /api/agents |
|
||||||
| P2-005 Status endpoints | ⬜ not-started | — | |
|
| P2-005 Status endpoints | ✅ done | #684 | Agent status API |
|
||||||
| P2-006 Chat routing | ⬜ not-started | — | |
|
| P2-006 Chat routing | ✅ done | #684 | Agent routing in chat proxy |
|
||||||
| P2-007 Discord routing | ⬜ not-started | — | |
|
| P2-007 Discord routing | ⬜ not-started | — | |
|
||||||
| P2-008 WebUI selector | ⬜ not-started | — | |
|
| P2-008 WebUI selector | ✅ done | #685 | AgentSelector component |
|
||||||
| 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 (done directly) |
|
| Schema+Seed+CRUD | 30K | ~15K |
|
||||||
| User CRUD+Routing | 40K | ~25K |
|
| User CRUD+Routing | 40K | ~25K |
|
||||||
| Discord+UI | 30K | — |
|
| Discord+UI | 30K | ~15K (P2-008 done) |
|
||||||
| Verification | 10K | — |
|
| Verification | 10K | — |
|
||||||
| **Total** | **110K** | **~40K** |
|
| **Total** | **110K** | **~55K** |
|
||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
| Date | Work Done |
|
| Date | Work Done |
|
||||||
| ---------- | --------------------------------------------------------------------------------------------------------- |
|
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
| 2026-03-04 | Session 2: Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete (4/6 remaining). |
|
| 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 | P2-001..003 shipped; CI fix; postgres rebuilt; mission initialized |
|
| 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 |
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
|
|||||||
| 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 | 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-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-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 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | — | |
|
| 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 | 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-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 | 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 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 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-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 | — | |
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
| ------- | ---------- | --------- | ---------------------- | ------------------------------------------------------------------------------ |
|
| ------- | ---------- | --------- | ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
| 2 | 2026-03-04 | M1+M2+M3 | P2-004 done | Fixed CI security audit, merged PRs #681, #678, #682. Milestones 1-3 complete. |
|
| 3 | 2026-03-05 | M4+M5 | P2-008 done | Fixed corrupted AgentSelector.tsx, integrated into Chat.tsx. PR #685 merged. 8/10 tasks done. |
|
||||||
| 1 | 2026-03-04 | M1+M2 | P2-001, P2-002, P2-003 | Schema, seed, and Admin CRUD complete |
|
| 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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user