From b6184b5bf3d095314e0043b76e1509df32a8d4cc 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, 19 Apr 2026 19:20:21 -0400 Subject: [PATCH] Document readAt viewer semantics and drop dead column from SELECTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentMessageRecord.readAt has always been a per-viewer field โ€” what the snapshot calls "readAt" means "the Board operator has acknowledged", what listAgentMessages calls "readAt" means "this agent has acknowledged". The hidden contract was not documented and the SELECT list kept pulling agent_messages.read_at even though no code writes it, inviting the same bug ยง1.1 just fixed. Spell out the viewer-scoped contract in snapshot.ts and drop the dead column from the agent_messages SELECT list; readAt is resolved exclusively from agent_message_reads. --- src/main/database/snapshot.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/database/snapshot.ts b/src/main/database/snapshot.ts index e088dbb7..44393504 100644 --- a/src/main/database/snapshot.ts +++ b/src/main/database/snapshot.ts @@ -36,6 +36,13 @@ export function buildSnapshotMethods( const connectors = deps.readConnectors(); const theme = (deps.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system"; const locale = (deps.getSetting("locale") as "en" | "zh" | null) ?? "en"; + // AgentMessageRecord.readAt is a per-viewer field โ€” its meaning is + // "has the current viewer acknowledged this message?". The renderer + // view has exactly one human viewer (the Board operator), so the + // renderer snapshot fills it from that reader's agent_message_reads + // rows. Agent-side queries (listAgentMessages) resolve it against + // the current agent's receipts. The raw agent_messages.read_at + // column is never read on any path and stays NULL by design. const boardReadReceiptMap = new Map( readAll<{ messageId: string; readAt: string }>( `select reads.message_id as messageId, reads.read_at as readAt @@ -176,13 +183,18 @@ export function buildSnapshotMethods( createdAt: r.created_at, updatedAt: r.updated_at, })), agentMessages: readAll( - `select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc limit ${companyId ? 1000 : 1000}`, + // The raw `read_at` column is intentionally omitted from the + // SELECT list. It is never written โ€” readAt semantics live in + // the agent_message_reads receipts table, resolved against the + // current viewer above. + `select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc limit 1000`, ...companyArgs, ).map((r) => ({ id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id, toAgentId: r.to_agent_id, senderKind: r.sender_kind ?? "agent", channel: r.channel, channelTargetId: r.channel_target_id, - subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null, + subject: r.subject, body: r.body, priority: r.priority, + readAt: boardReadReceiptMap.get(r.id) ?? null, parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json, createdAt: r.created_at, })),