From ce2c3be0654840d87598e73f621c4de5b62a0b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 14:29:59 -0400 Subject: [PATCH] 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 --- src/main/database.ts | 20 ++ src/main/ipc-agent-coordination-handlers.ts | 12 ++ src/preload/index.ts | 1 + src/shared/contracts.ts | 12 +- tests/unit/message-search.test.ts | 207 ++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 tests/unit/message-search.test.ts diff --git a/src/main/database.ts b/src/main/database.ts index a6e9c4e4..9a934214 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -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(sql, ...params); + } + markMessageRead(messageId: string, companyId: string) { this.markAgentMessageRead(messageId, companyId, BOARD_READER_ID); } diff --git a/src/main/ipc-agent-coordination-handlers.ts b/src/main/ipc-agent-coordination-handlers.ts index eb6ab625..309e9370 100644 --- a/src/main/ipc-agent-coordination-handlers.ts +++ b/src/main/ipc-agent-coordination-handlers.ts @@ -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); diff --git a/src/preload/index.ts b/src/preload/index.ts index 043e1b6e..d55e5e99 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 249bd119..b2648bf6 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -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) => Promise>; runWorkflow: (input: z.infer) => Promise>; autoAssignTask: (input: z.infer) => Promise>; + searchMessages: (input: z.infer) => Promise>; recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise>; recoveryDismiss: (taskId: string) => Promise>; recoveryFail: (taskId: string) => Promise>; diff --git a/tests/unit/message-search.test.ts b/tests/unit/message-search.test.ts new file mode 100644 index 00000000..91412320 --- /dev/null +++ b/tests/unit/message-search.test.ts @@ -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); + } + }); +});