diff --git a/packages/config/local/inbox.test.ts b/packages/config/local/inbox.test.ts index 27aff079..a7b80985 100644 --- a/packages/config/local/inbox.test.ts +++ b/packages/config/local/inbox.test.ts @@ -150,6 +150,78 @@ describe("local inbox store", () => { expect(summary?.taskTitle).toBe("Check deploy status after 1h"); }); + it("preserves task/cron source_kind when a later ensureMessageThread call defaults to user", () => { + // Simulates the bug where a synthetic task thread was later re-ensured + // through a path (e.g. runtime-facade) that hardcodes sourceKind: "user". + const taskThreadKey = buildThreadKey("C-preserve", "task:task-99"); + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "task:task-99", + replyThreadId: "task:task-99", + sourceKind: "task", + taskId: "task-99", + taskTitle: "do the thing later", + }); + + // Second call without explicit sourceKind (defaults to "user") must NOT + // clobber the more specific "task" source already persisted. + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "task:task-99", + replyThreadId: "task:task-99", + }); + + const taskDetail = getMessageThreadById(taskThreadKey); + expect(taskDetail?.sourceKind).toBe("task"); + expect(taskDetail?.taskId).toBe("task-99"); + expect(taskDetail?.taskTitle).toBe("do the thing later"); + + // Same protection for cron_job. + const cronThreadKey = buildThreadKey("C-preserve", "cron-job:job-99"); + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "cron-job:job-99", + replyThreadId: "cron-job:job-99", + sourceKind: "cron_job", + cronJobId: "job-99", + cronJobTitle: "nightly cron", + }); + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "cron-job:job-99", + replyThreadId: "cron-job:job-99", + sourceKind: "user", + }); + const cronDetail = getMessageThreadById(cronThreadKey); + expect(cronDetail?.sourceKind).toBe("cron_job"); + expect(cronDetail?.cronJobId).toBe("job-99"); + + // Sanity check: a plain user thread upgraded to task still reflects the + // more specific value (upgrades user -> task are allowed). + const upgradeKey = buildThreadKey("C-preserve", "T-upgrade"); + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "T-upgrade", + replyThreadId: "T-upgrade", + }); + ensureMessageThread({ + platform: "slack", + channelId: "C-preserve", + threadId: "T-upgrade", + replyThreadId: "T-upgrade", + sourceKind: "task", + taskId: "task-upgrade", + }); + const upgraded = getMessageThreadById(upgradeKey); + expect(upgraded?.sourceKind).toBe("task"); + expect(upgraded?.taskId).toBe("task-upgrade"); + }); + it("captures a full question → reply → answer batch timeline", () => { const threadKey = buildThreadKey("C-q", "T-q"); ensureMessageThread({ diff --git a/packages/config/local/inbox.ts b/packages/config/local/inbox.ts index 73bf6583..5d3c2c06 100644 --- a/packages/config/local/inbox.ts +++ b/packages/config/local/inbox.ts @@ -632,7 +632,10 @@ export function ensureMessageThread(params: EnsureMessageThreadParams): string { working_directory = COALESCE(excluded.working_directory, message_thread.working_directory), thread_owner_user_id = COALESCE(excluded.thread_owner_user_id, message_thread.thread_owner_user_id), branch_name = COALESCE(excluded.branch_name, message_thread.branch_name), - source_kind = excluded.source_kind, + source_kind = CASE + WHEN message_thread.source_kind IN ('task','cron_job') THEN message_thread.source_kind + ELSE excluded.source_kind + END, cron_job_id = COALESCE(excluded.cron_job_id, message_thread.cron_job_id), cron_job_title = COALESCE(excluded.cron_job_title, message_thread.cron_job_title), task_id = COALESCE(excluded.task_id, message_thread.task_id),