Q11: deleted messages and withdrawn commands never reach the model

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 03:47:57 -04:00
1 parent 31ab919334
commit 22632a4695
8 files changed
+91 -28

No files matched your search

@@ -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'));
`;
+2
View File
@@ -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;
+5 -20
View File
@@ -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");
}
}
+5 -4
View File
@@ -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;
+34 -1
View File
@@ -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();
});
});
+5 -2
View File
@@ -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,
@@ -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();
});
});
+1 -1
View File
@@ -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();
});