From 22632a469562090beda1e0740dc19a6168e25689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 03:47:57 -0400 Subject: [PATCH] Q11: deleted messages and withdrawn commands never reach the model Co-Authored-By: Claude Opus 5.5 --- .../migrations/060-chat-wake-text-purge.ts | 9 +++++ src/main/database/schema.ts | 2 ++ src/main/services/chat-agent-wake-service.ts | 25 +++---------- src/main/services/chat-wake-context.ts | 9 ++--- tests/integration/chat-context.test.ts | 35 ++++++++++++++++++- tests/unit/chat-governance.test.ts | 7 ++-- .../chat-wake-text-purge-migration.test.ts | 30 ++++++++++++++++ tests/unit/domain-services.test.ts | 2 +- 8 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 src/main/database/migrations/060-chat-wake-text-purge.ts create mode 100644 tests/unit/chat-wake-text-purge-migration.test.ts diff --git a/src/main/database/migrations/060-chat-wake-text-purge.ts b/src/main/database/migrations/060-chat-wake-text-purge.ts new file mode 100644 index 00000000..2a23d169 --- /dev/null +++ b/src/main/database/migrations/060-chat-wake-text-purge.ts @@ -0,0 +1,9 @@ +export const chatWakeTextPurgeSchema = ` + update tasks + set title = case when title like 'Chat mention:%' then 'Reply in chat' else title end, + description = 'Reply in the source thread with send_message and its thread_id; the full message is in the thread below.' + where origin = 'chat_wake'; + + insert into schema_migrations(version, name, applied_at) + values (60, 'chat_wake_text_purge', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index b29baa6c..f5d93a0e 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -58,6 +58,7 @@ import { kimiProvidersSchema } from "./migrations/056-kimi-providers"; import { kimiCodeProviderSchema } from "./migrations/057-kimi-code-provider"; import { integrationKeysSchema } from "./migrations/058-integration-keys"; import { taskHandoffSchema } from "./migrations/059-task-handoff"; +import { chatWakeTextPurgeSchema } from "./migrations/060-chat-wake-text-purge"; interface Migration { version: number; @@ -125,6 +126,7 @@ const MIGRATIONS: Migration[] = [ { version: 57, name: "kimi_code_provider", sql: kimiCodeProviderSchema }, { version: 58, name: "integration_keys", sql: integrationKeysSchema }, { version: 59, name: "task_handoff", sql: taskHandoffSchema }, + { version: 60, name: "chat_wake_text_purge", sql: chatWakeTextPurgeSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/chat-agent-wake-service.ts b/src/main/services/chat-agent-wake-service.ts index f227a5e8..711ba2b8 100644 --- a/src/main/services/chat-agent-wake-service.ts +++ b/src/main/services/chat-agent-wake-service.ts @@ -150,10 +150,13 @@ export class ChatAgentWakeService { const targetId = this.requireAgentMention(mentions); const agent = this.requireRunnableAgent(input.orgId, targetId, "Slash command /agent"); const workspace = this.workspaceForAgent(input.orgId, agent.workspace_id); + const from = this.principalName(input.orgId, input.senderType, input.senderId); + const space = this.spaceRow(input.orgId, input.spaceId); + const direct = space.space_type === "direct"; const taskId = this.organization.createTask({ orgId: input.orgId, - title: this.taskTitle(agent.name, slash), - description: this.taskDescription(input, slash, messageId, threadId), + title: direct ? `Reply to ${from}` : `Reply to ${from} in #${space.name}`, + description: this.mentionTaskDescription(from, space.name, direct), status: TASK_STATUS.BACKLOG, assigneeAgentId: agent.id, risk: RISK_LEVEL.LOW, @@ -290,12 +293,6 @@ export class ChatAgentWakeService { `).run("agent", agentId, "thinking", slash.args.trim().slice(0, 160), now); } - private taskTitle(agentName: string, slash: SlashInvocation): string { - const details = slash.args.replace(/^@[^\s]+\s*/, "").trim(); - const title = details || `reply as ${agentName}`; - return `Chat mention: ${title.length > 64 ? `${title.slice(0, 61)}...` : title}`; - } - private mentionTaskDescription(from: string, channel: string, direct: boolean): string { const where = direct ? `${from} messaged you directly; their full message is in the thread below.` @@ -328,16 +325,4 @@ export class ChatAgentWakeService { if (!row) throw new Error("Chat space was not found."); return row; } - - private taskDescription(input: SendMessageInput, slash: SlashInvocation, messageId: string, threadId: string): string { - return [ - `Chat command: /agent ${slash.args.trim()}`, - `Source message: ${messageId}`, - `Source thread: ${threadId}`, - `Source space: ${input.spaceId}`, - `Sender: ${input.senderType}${input.senderId ? `:${input.senderId}` : ""}`, - "", - "Reply in the source thread with send_message and pass the thread_id above.", - ].join("\n"); - } } diff --git a/src/main/services/chat-wake-context.ts b/src/main/services/chat-wake-context.ts index cc5a0ced..3447d634 100644 --- a/src/main/services/chat-wake-context.ts +++ b/src/main/services/chat-wake-context.ts @@ -30,15 +30,16 @@ export function loadChatWakeContext(db: DatabaseSync, orgId: string, agentId: st if (!thread) throw new Error(`Chat wake thread ${scope.sourceThreadId} no longer exists.`); const lastReplyAt = (db.prepare(` select coalesce(max(created_at), '') as at from chat_messages - where thread_id = ? and org_id = ? and sender_type = 'agent' and sender_id = ? + where thread_id = ? and org_id = ? and sender_type = 'agent' and sender_id = ? and deleted_at is null `).get(scope.sourceThreadId, orgId, agentId) as { at: string }).at; const isFresh = `not (m.sender_type = 'agent' and m.sender_id = ?) and (m.created_at > ? or coalesce(m.edited_at, '') > ?)`; + const inThread = "m.thread_id = ? and m.org_id = ? and m.deleted_at is null"; const args = [scope.sourceThreadId, orgId, agentId, lastReplyAt, lastReplyAt]; - const fresh = db.prepare(`${MESSAGE_SELECT} where m.thread_id = ? and m.org_id = ? and ${isFresh} order by m.created_at asc`) + const fresh = db.prepare(`${MESSAGE_SELECT} where ${inThread} and ${isFresh} order by m.created_at asc`) .all(...args) as unknown as ThreadMessageRow[]; - const history = (db.prepare(`${MESSAGE_SELECT} where m.thread_id = ? and m.org_id = ? and not (${isFresh}) order by m.created_at desc limit ?`) + const history = (db.prepare(`${MESSAGE_SELECT} where ${inThread} and not (${isFresh}) order by m.created_at desc limit ?`) .all(...args, HISTORY_LIMIT) as unknown as ThreadMessageRow[]).reverse(); - const historyTotal = (db.prepare(`select count(*) as n from chat_messages m where m.thread_id = ? and m.org_id = ? and not (${isFresh})`) + const historyTotal = (db.prepare(`select count(*) as n from chat_messages m where ${inThread} and not (${isFresh})`) .get(...args) as { n: number }).n; if (fresh.length === 0 && history.length === 0) return null; const omitted = historyTotal - history.length; diff --git a/tests/integration/chat-context.test.ts b/tests/integration/chat-context.test.ts index 7a1cee9d..ab1dddb3 100644 --- a/tests/integration/chat-context.test.ts +++ b/tests/integration/chat-context.test.ts @@ -73,7 +73,11 @@ async function founderLine() { connection.db.prepare("update task_runs set state = 'failed' where id = ?").run(wake.id); await execution.runTask({ orgId, taskId: wake.task_id }); }; - return { connection, wakePrompts, wakeInputs, say, runWake, runManually, edit, onWakeTurn }; + const remove = (bodyMd: string) => { + const row = connection.db.prepare("select id from chat_messages where body_md = ?").get(bodyMd) as { id: string }; + chat.deleteMessage({ orgId, messageId: row.id, actorType: "employee", actorId: founderId }); + }; + return { connection, wakePrompts, wakeInputs, say, runWake, runManually, edit, remove, onWakeTurn }; } describe("chat wake context", () => { @@ -224,4 +228,33 @@ describe("chat wake context", () => { expect(h.wakePrompts[0]).toContain("Please draft the Q3 hiring plan."); h.connection.close(); }); + + test("deleted messages never reach the model", async () => { + const h = await founderLine(); + const threadId = h.say("Old plan: launch in March.", null); + await h.runWake(); + h.remove("Old plan: launch in March."); + h.say("Wrong number, ignore it: 4242.", threadId); + h.remove("Wrong number, ignore it: 4242."); + h.say("What is the launch date now?", threadId); + await h.runWake(); + const prompt = h.wakePrompts[1]; + expect(prompt).toContain("What is the launch date now?"); + expect(prompt).not.toContain("Old plan: launch in March."); + expect(prompt).not.toContain("4242"); + h.connection.close(); + }); + + test("a deleted /agent command leaves no trace in the task or the prompt", async () => { + const h = await founderLine(); + const threadId = h.say("/agent @ceo wire the payroll to account 99-1234", null); + h.remove("/agent @ceo wire the payroll to account 99-1234"); + h.say("Never mind that. How is hiring going?", threadId); + await h.runWake(); + const prompt = h.wakePrompts[0]; + expect(prompt).toContain("How is hiring going?"); + expect(prompt).not.toContain("99-1234"); + expect(h.connection.db.prepare("select count(*) as n from tasks where title like '%99-1234%' or description like '%99-1234%'").get()).toEqual({ n: 0 }); + h.connection.close(); + }); }); diff --git a/tests/unit/chat-governance.test.ts b/tests/unit/chat-governance.test.ts index 65145a7d..bed66a50 100644 --- a/tests/unit/chat-governance.test.ts +++ b/tests/unit/chat-governance.test.ts @@ -328,7 +328,8 @@ describe("chat governance", () => { expect(slashByMessage.get(fileMessageId)).toMatchObject({ command: "file", resultRef: fileId }); expect(slashByMessage.get(runbookMessageId)).toMatchObject({ command: "runbook", resultRef: runbookMessageId }); expect(slashByMessage.get(handoffMessageId)).toMatchObject({ command: "handoff", resultRef: fileId }); - const agentTask = state.tasks.find((item) => item.assigneeAgentId === agentId && item.title === "Chat mention: review the gate"); + const wakeTaskId = (connection.db.prepare("select id from tasks where assignee_agent_id = ? and origin = 'chat_wake'").get(agentId) as { id: string }).id; + const agentTask = state.tasks.find((item) => item.id === wakeTaskId); expect(agentTask).toMatchObject({ orgId, assigneeAgentId: agentId, @@ -337,7 +338,9 @@ describe("chat governance", () => { priority: "high", requiresFilesystem: false, }); - expect(agentTask?.description).toContain(`Source message: ${agentMessageId}`); + expect(agentTask?.title).toMatch(/^Reply to /); + expect(agentTask?.description).toContain("their full message is in the thread below"); + expect(agentTask?.description).not.toContain("review the gate"); const agentRun = state.taskRuns.find((item) => item.taskId === agentTask?.id); expect(agentRun).toMatchObject({ orgId, diff --git a/tests/unit/chat-wake-text-purge-migration.test.ts b/tests/unit/chat-wake-text-purge-migration.test.ts new file mode 100644 index 00000000..7160e3f2 --- /dev/null +++ b/tests/unit/chat-wake-text-purge-migration.test.ts @@ -0,0 +1,30 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, test } from "vitest"; +import { chatWakeTextPurgeSchema } from "../../src/main/database/migrations/060-chat-wake-text-purge"; + +describe("chat wake text purge migration", () => { + test("strips copied message text from chat-wake tasks and leaves real work alone", () => { + const db = new DatabaseSync(join(tmpdir(), `agentcompany-purge-migration-${randomUUID()}.db`)); + db.exec(` + create table schema_migrations(version integer primary key, name text not null, applied_at text not null); + create table tasks(id text primary key, title text not null, description text not null, origin text); + insert into tasks values ('tsk_slash', 'Chat mention: wire payroll to 99-1234', 'Chat command: /agent @ceo wire payroll to 99-1234', 'chat_wake'); + insert into tasks values ('tsk_mention', 'Reply to Yang', 'Yang messaged you directly:\n"secret plan..."', 'chat_wake'); + insert into tasks values ('tsk_real', 'Chat mention: keep', 'Real description', null); + `); + db.exec(chatWakeTextPurgeSchema); + const rows = Object.fromEntries((db.prepare("select id, title, description from tasks").all() as Array<{ id: string; title: string; description: string }>) + .map((row) => [row.id, [row.title, row.description]])); + const generic = "Reply in the source thread with send_message and its thread_id; the full message is in the thread below."; + expect(rows).toEqual({ + tsk_slash: ["Reply in chat", generic], + tsk_mention: ["Reply to Yang", generic], + tsk_real: ["Chat mention: keep", "Real description"], + }); + expect(db.prepare("select version from schema_migrations").all()).toEqual([{ version: 60 }]); + db.close(); + }); +}); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index be28145e..a65d12cb 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60]); connection.close(); });