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>>;