feat(03-01): add searchMessages backend with LIKE query, IPC channel, preload bridge, and tests
- Add searchMessages method to AppDatabase with LIKE query, wildcard escaping, channel scoping, ORDER BY created_at DESC with limit - Add searchMessages IPC channel and searchMessagesSchema to contracts.ts - Register IPC handler in ipc-agent-coordination-handlers.ts with Zod validation - Wire searchMessages through preload bridge - Add 8 unit tests covering body/subject search, wildcard escaping, channel scoping, empty results, limit, and ordering
This commit is contained in:
1 parent
14da6b5ede
commit
ce2c3be065
5 files changed
+251
-1
No files matched your search
@@ -2675,6 +2675,26 @@ export class AppDatabase {
|
||||
return limit > 0 ? messages.slice(-limit) : messages;
|
||||
}
|
||||
|
||||
searchMessages(companyId: string, query: string, opts?: {
|
||||
channel?: string;
|
||||
channelTargetId?: string | null;
|
||||
limit?: number;
|
||||
}): AgentMessageRecord[] {
|
||||
const escaped = query.replace(/[%_]/g, "\\$&");
|
||||
const pattern = `%${escaped}%`;
|
||||
let sql = `SELECT id, company_id AS companyId, from_agent_id AS fromAgentId,
|
||||
to_agent_id AS toAgentId, channel, channel_target_id AS channelTargetId,
|
||||
subject, body, priority, read_at AS readAt, parent_message_id AS parentMessageId,
|
||||
attachments_json AS attachmentsJson, created_at AS createdAt
|
||||
FROM agent_messages WHERE company_id = ? AND (subject LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')`;
|
||||
const params: SQLInputValue[] = [companyId, pattern, pattern];
|
||||
if (opts?.channel) { sql += " AND channel = ?"; params.push(opts.channel); }
|
||||
if (opts?.channelTargetId !== undefined && opts?.channelTargetId !== null) { sql += " AND channel_target_id = ?"; params.push(opts.channelTargetId); }
|
||||
sql += " ORDER BY created_at DESC LIMIT ?";
|
||||
params.push(opts?.limit ?? 50);
|
||||
return this.readAll<AgentMessageRecord>(sql, ...params);
|
||||
}
|
||||
|
||||
markMessageRead(messageId: string, companyId: string) {
|
||||
this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
autoAssignSchema,
|
||||
markMessageReadSchema,
|
||||
messageQuerySchema,
|
||||
searchMessagesSchema,
|
||||
} from "@shared/contracts";
|
||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||
import { dispatchAgentMessage } from "./message-dispatch";
|
||||
@@ -94,6 +95,17 @@ export function registerAgentCoordinationHandlers({
|
||||
return ok(true);
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.searchMessages, async (_event, payload) => {
|
||||
const parsed = searchMessagesSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
if (companyError) return companyError;
|
||||
return ok(db.searchMessages(parsed.companyId, parsed.query, {
|
||||
channel: parsed.channel,
|
||||
channelTargetId: parsed.channelTargetId,
|
||||
limit: parsed.limit,
|
||||
}));
|
||||
});
|
||||
|
||||
registerHandle(IPC_CHANNELS.autoAssignTask, async (_event, payload) => {
|
||||
const parsed = autoAssignSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
|
||||
@@ -70,6 +70,7 @@ const api: DesktopApi = {
|
||||
getAutomationLog: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getAutomationLog, companyId),
|
||||
sendAgentMessage: (input) => ipcRenderer.invoke(IPC_CHANNELS.sendAgentMessage, input),
|
||||
listAgentMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.listAgentMessages, input),
|
||||
searchMessages: (input) => ipcRenderer.invoke(IPC_CHANNELS.searchMessages, input),
|
||||
markMessageRead: (input) => ipcRenderer.invoke(IPC_CHANNELS.markMessageRead, input),
|
||||
saveWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.saveWorkflow, input),
|
||||
deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input),
|
||||
|
||||
+11
-1
@@ -72,6 +72,7 @@ export const IPC_CHANNELS = {
|
||||
deleteWorkflow: "agent-company:delete-workflow",
|
||||
runWorkflow: "agent-company:run-workflow",
|
||||
autoAssignTask: "agent-company:auto-assign-task",
|
||||
searchMessages: "agent-company:search-messages",
|
||||
} as const;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
@@ -179,7 +180,7 @@ export const approvalInputSchema = z.object({
|
||||
relatedTaskId: optionalId,
|
||||
requestedByAgentId: optionalId,
|
||||
relatedAgentId: optionalId,
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation"]),
|
||||
type: z.enum(["hire_agent", "approve_ceo_strategy", "dangerous_command", "secret_access", "budget_request", "promotion", "termination", "vendor_approval", "document_review", "production_deploy", "incident_escalation", "social_post"]),
|
||||
payloadSummary: z.string().min(2).max(280),
|
||||
impactSummary: z.string().min(2).max(400),
|
||||
payloadJson: z.string().nullable().optional(),
|
||||
@@ -465,6 +466,14 @@ export const messageQuerySchema = z.object({
|
||||
limit: z.number().int().min(1).max(500).default(200),
|
||||
});
|
||||
|
||||
export const searchMessagesSchema = z.object({
|
||||
companyId: z.string().uuid(),
|
||||
query: z.string().min(1).max(200),
|
||||
channel: z.enum(["direct", "department", "company", "project", "incident"]).optional(),
|
||||
channelTargetId: z.string().nullable().optional(),
|
||||
limit: z.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
export const workflowInputSchema = z.object({
|
||||
id: z.preprocess((v) => (v === "" ? null : v), z.string().uuid().nullable().optional()),
|
||||
companyId: z.string().uuid(),
|
||||
@@ -577,6 +586,7 @@ export type DesktopApi = {
|
||||
deleteWorkflow: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
runWorkflow: (input: z.infer<typeof runWorkflowSchema>) => Promise<DesktopResult<boolean>>;
|
||||
autoAssignTask: (input: z.infer<typeof autoAssignSchema>) => Promise<DesktopResult<string>>;
|
||||
searchMessages: (input: z.infer<typeof searchMessagesSchema>) => Promise<DesktopResult<AgentMessageRecord[]>>;
|
||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryDismiss: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryFail: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
describe("AppDatabase.searchMessages", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let companyId: string;
|
||||
let agentA: string;
|
||||
let agentB: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-search-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
companyId = db.saveCompany({
|
||||
name: "Search Test Corp",
|
||||
description: "Company for testing message search",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
agentA = db.saveAgent({
|
||||
companyId,
|
||||
name: "Agent Alpha",
|
||||
role: "Engineer",
|
||||
title: "Senior Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "coding",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
|
||||
agentB = db.saveAgent({
|
||||
companyId,
|
||||
name: "Agent Beta",
|
||||
role: "Designer",
|
||||
title: "Lead Designer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "design",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
|
||||
// Seed messages across different channels
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
toAgentId: agentB,
|
||||
channel: "direct",
|
||||
subject: "Database migration plan",
|
||||
body: "We need to migrate the PostgreSQL tables to the new schema.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentB,
|
||||
toAgentId: agentA,
|
||||
channel: "direct",
|
||||
subject: "Design review feedback",
|
||||
body: "The button colors look great, but the migration banner needs work.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: "Sprint retrospective",
|
||||
body: "Team velocity improved by 20% this sprint.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "department",
|
||||
channelTargetId: "engineering",
|
||||
subject: "Code review standards",
|
||||
body: "All PRs must have at least two approvals before merging.",
|
||||
priority: "urgent",
|
||||
});
|
||||
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentB,
|
||||
channel: "department",
|
||||
channelTargetId: "design",
|
||||
subject: "Design system update",
|
||||
body: "We updated the color tokens for dark mode compatibility.",
|
||||
priority: "low",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns messages where body contains the query string", () => {
|
||||
const results = db.searchMessages(companyId, "velocity");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].companyId).toBe(companyId);
|
||||
expect(results[0].body.toLowerCase()).toContain("velocity");
|
||||
});
|
||||
|
||||
it("returns messages where subject contains the query string", () => {
|
||||
const results = db.searchMessages(companyId, "retrospective");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].subject).toBe("Sprint retrospective");
|
||||
});
|
||||
|
||||
it("escapes LIKE wildcards so % and _ match literally", () => {
|
||||
// Insert a message with literal % and _ characters
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: "Performance metrics",
|
||||
body: "CPU usage was 95% and disk_io dropped significantly.",
|
||||
priority: "normal",
|
||||
});
|
||||
|
||||
// Search for the literal "95%"
|
||||
const percentResults = db.searchMessages(companyId, "95%");
|
||||
expect(percentResults.length).toBe(1);
|
||||
expect(percentResults[0].body).toContain("95%");
|
||||
|
||||
// Search for the literal "disk_io"
|
||||
const underscoreResults = db.searchMessages(companyId, "disk_io");
|
||||
expect(underscoreResults.length).toBe(1);
|
||||
expect(underscoreResults[0].body).toContain("disk_io");
|
||||
});
|
||||
|
||||
it("scopes to a specific channel when channel param is provided", () => {
|
||||
const results = db.searchMessages(companyId, "approvals", { channel: "department" });
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].channel).toBe("department");
|
||||
expect(results[0].body).toContain("approvals");
|
||||
});
|
||||
|
||||
it("scopes to channelTargetId when provided alongside channel", () => {
|
||||
const results = db.searchMessages(companyId, "updated", {
|
||||
channel: "department",
|
||||
channelTargetId: "design",
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].channelTargetId).toBe("design");
|
||||
});
|
||||
|
||||
it("returns empty array when no matches", () => {
|
||||
const results = db.searchMessages(companyId, "xyznonexistentquery");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects the limit parameter (default 50)", () => {
|
||||
// Insert 60 messages
|
||||
for (let i = 0; i < 60; i++) {
|
||||
db.sendAgentMessage({
|
||||
companyId,
|
||||
fromAgentId: agentA,
|
||||
channel: "company",
|
||||
subject: `Bulk message ${i}`,
|
||||
body: `This is a flood test message number ${i}`,
|
||||
priority: "normal",
|
||||
});
|
||||
}
|
||||
|
||||
// Default limit should be 50
|
||||
const defaultResults = db.searchMessages(companyId, "flood test");
|
||||
expect(defaultResults.length).toBe(50);
|
||||
|
||||
// Explicit limit of 10
|
||||
const limitedResults = db.searchMessages(companyId, "flood test", { limit: 10 });
|
||||
expect(limitedResults.length).toBe(10);
|
||||
});
|
||||
|
||||
it("returns results ordered by created_at DESC (most recent first)", () => {
|
||||
const results = db.searchMessages(companyId, "migration");
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const prev = new Date(results[i - 1].createdAt).getTime();
|
||||
const curr = new Date(results[i].createdAt).getTime();
|
||||
expect(prev).toBeGreaterThanOrEqual(curr);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user