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:
ZacharyZhang-NY committed 2026-03-22 14:29:59 -04:00
1 parent 14da6b5ede
commit ce2c3be065
5 files changed
+251 -1

No files matched your search

+20
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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>>;
+207
View File
@@ -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);
}
});
});