fix(slack): preserve explicit DM task controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadottaandPaperclip committed 2026-11-06 16:14:02 +00:00
1 parent 78108efc0c
commit 367b6f9b3f
6 files changed
+33 -26

No files matched your search

+4 -2
View File
@@ -104,8 +104,10 @@ rules still apply, and repeated Slack deliveries do not create duplicate tasks.
A native Slack thread keeps the same task in channels and DMs, including after A native Slack thread keeps the same task in channels and DMs, including after
the task is Done or Cancelled. A new reply reopens that task with its saved the task is Done or Cancelled. A new reply reopens that task with its saved
comments. DM access settings still apply to threads inside DMs. Top-level DMs comments. DM access settings and task controls still apply to threads inside DMs.
keep their linear lifecycle and can start a new task after completion. An explicit `/new` or `/close` starts the next task generation only after its
control response is delivered. Task completion alone does not release the binding.
Top-level DMs keep their linear lifecycle and can start a new task after completion.
For a thread that was split before this fix, later replies continue its latest For a thread that was split before this fix, later replies continue its latest
saved task. Earlier tasks remain separate; this change does not merge history saved task. Earlier tasks remain separate; this change does not merge history
or copy messages from the surrounding DM into a new thread. or copy messages from the surrounding DM into a new thread.
+1 -6
View File
@@ -654,12 +654,7 @@ retrieval or telephone playback is established by the UI fixtures.
Slack's **Require at-mention** policy applies to channel messages and channel Slack's **Require at-mention** policy applies to channel messages and channel
thread replies, including queued work. DMs are exempt from mention checks; thread replies, including queued work. DMs are exempt from mention checks;
endpoint availability, **Allow direct messages**, and sender authorization still endpoint availability, **Allow direct messages**, and sender authorization still
apply. Top-level DM replies retain their linear conversation destination. Native apply. Top-level DM replies retain their linear conversation destination. Source:
DM threads retain their task across completion while keeping the `direct_message`
resource type and the DM reach gate. Duplicate delivery does not add another
comment or wake. These cases have PostgreSQL fixture coverage in the
[chat integration suite](../../server/src/__tests__/chat-channels.integration.test.ts).
Source:
[admission](../../server/src/services/chat-channels.ts) and [admission](../../server/src/services/chat-channels.ts) and
[queued run policy](../../server/src/services/durable-chat-wakeup.ts). Live Slack [queued run policy](../../server/src/services/durable-chat-wakeup.ts). Live Slack
verification of this exemption remains separate from provider-fixture coverage. verification of this exemption remains separate from provider-fixture coverage.
+3 -9
View File
@@ -101,15 +101,9 @@ cover the database and prompt contracts; they do not prove live provider deliver
For Slack, enable **Require at-mention** and **Allow direct messages**. Send a For Slack, enable **Require at-mention** and **Allow direct messages**. Send a
plain DM and follow up without a mention: both should reach the agent, with plain DM and follow up without a mention: both should reach the agent, with
top-level replies to top-level DMs. Channel messages and channel thread replies top-level replies to top-level DMs. Channel messages and channel thread replies still
still require a mention when that setting is enabled. Finish a task inside a DM require a mention when that setting is enabled. Disable direct messages and
thread, then reply in that same thread. The reply must reopen the same task and confirm a plain DM is rejected. Threads also work in DMs.
retain its earlier comments. A different thread root must use a different task.
Disable direct messages and confirm both plain and threaded DMs are rejected.
The [completion regression](../../server/src/__tests__/chat-channels.integration.test.ts)
covers Done, Cancelled, completed bindings, duplicate delivery, and bare-DM and
channel-thread controls with PostgreSQL and provider fixtures. This is not live
Slack verification.
For Slack acknowledgement latency, mention the bot with and without an attachment. For Slack acknowledgement latency, mention the bot with and without an attachment.
Eyes should appear after authorized task/comment persistence, independently of Eyes should appear after authorized task/comment persistence, independently of
@@ -58500,7 +58500,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
]); ]);
}); });
it("binds Slack DM slash controls to the native root returned for the latest task", async () => { it.each(["new", "close"] as const)("binds Slack DM slash controls to the native root returned for the latest task (%s)", async (control) => {
const fixture = await seedCompany(); const fixture = await seedCompany();
const { callbacks, endpoint, runtime, service } = const { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture); await configuredSlackEndpoint(fixture);
@@ -58564,7 +58564,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
threadId: starterThreadId, threadId: starterThreadId,
text: expect.stringMatching(/— todo$/), text: expect.stringMatching(/— todo$/),
}); });
await invoke("close"); await invoke(control);
await expect(service.listConversations(endpoint.id)).resolves.toEqual([ await expect(service.listConversations(endpoint.id)).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
externalThreadId: starterThreadId, externalThreadId: starterThreadId,
@@ -58578,9 +58578,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
state: "completed", state: "completed",
}), }),
]); ]);
expect( expect(await readIssuesByCompanyId(fixture.companyId)).toHaveLength(1);
await readIssuesByCompanyId(fixture.companyId), const providerTime = Date.now();
).toHaveLength(1); const providerTimestamp = `${Math.floor(providerTime / 1_000)}.${String((providerTime % 1_000) * 1_000).padStart(6, "0")}`;
await deliverMessage({
callbacks, endpointId: endpoint.id,
thread: makeThread({ channelId: "D09SLASHTASK", id: starterThreadId, isDM: true }).thread,
message: makeMessage({ id: providerTimestamp, raw: { ts: providerTimestamp }, text: "An explicitly requested new task", userId: "U-DM-SLASH-TASK" }),
trigger: "direct_message",
});
const conversations = await service.listConversations(endpoint.id);
expect(conversations).toHaveLength(2);
expect(conversations.find(row => row.state === "active")).toMatchObject({
externalThreadId: starterThreadId, sessionGeneration: 2,
});
expect(await readIssuesByCompanyId(fixture.companyId)).toHaveLength(2);
}); });
it("acknowledges a Slack retry while one durable worker owns the unrecorded command root", async () => { it("acknowledges a Slack retry while one durable worker owns the unrecorded command root", async () => {
+6 -2
View File
@@ -1749,13 +1749,17 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
let { conversation: existingConversation, issue: existingIssue, latestConversation, isLinear } = let { conversation: existingConversation, issue: existingIssue, latestConversation, isLinear } =
await resolveInboundConversationBinding(db, { await resolveInboundConversationBinding(db, {
endpoint, thread, endpoint, thread,
isLinear: () => endpoint.provider === "speko" ? SPEKO_CONVERSATION_IS_LINEAR : surfaceKind !== "native_thread", isLinear: () => endpoint.provider === "speko"
? SPEKO_CONVERSATION_IS_LINEAR
: surfaceKind !== "native_thread" || (endpoint.provider === "slack" && thread.isDM),
lookupConversation: endpoint.provider === "microsoft-teams" lookupConversation: endpoint.provider === "microsoft-teams"
? () => conversationForThread(endpoint.id, thread.id) ? () => conversationForThread(endpoint.id, thread.id)
: undefined, : undefined,
conversationIsComplete: endpoint.provider === "imessage-photon" conversationIsComplete: endpoint.provider === "imessage-photon"
? (conversationId) => photonConversationBindingIsComplete(conversationId, hasCommittedTaskControlCompletion) ? (conversationId) => photonConversationBindingIsComplete(conversationId, hasCommittedTaskControlCompletion)
: undefined, : endpoint.provider === "slack" && thread.isDM && surfaceKind === "native_thread"
? async (conversationId, state) => state === "completed" && hasCommittedTaskControlCompletion(conversationId)
: undefined,
}); });
const telegramControls = endpoint.provider === "telegram" const telegramControls = endpoint.provider === "telegram"
? telegramInboundControls(message.text, surfaceKind) ? telegramInboundControls(message.text, surfaceKind)
@@ -9,7 +9,7 @@ type ConversationBindingInput = {
thread: { id: string; channelId: string }; thread: { id: string; channelId: string };
isLinear: () => boolean; isLinear: () => boolean;
lookupConversation?: () => Promise<ConversationRow | null>; lookupConversation?: () => Promise<ConversationRow | null>;
conversationIsComplete?: (conversationId: string) => Promise<boolean>; conversationIsComplete?: (conversationId: string, state: ConversationRow["state"]) => Promise<boolean>;
}; };
export async function resolveInboundConversationBinding( export async function resolveInboundConversationBinding(
@@ -54,7 +54,7 @@ export async function resolveInboundConversationBinding(
isLinear && isLinear &&
existingConversation && existingConversation &&
(conversationIsComplete (conversationIsComplete
? await conversationIsComplete(existingConversation.id) ? await conversationIsComplete(existingConversation.id, existingConversation.state)
: existingConversation.state === "completed" || : existingConversation.state === "completed" ||
existingIssue?.status === "done" || existingIssue?.status === "done" ||
existingIssue?.status === "cancelled") existingIssue?.status === "cancelled")