fix(slack): preserve explicit DM task controls

Co-Authored-By: Paperclip <[email protected]>
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
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
keep their linear lifecycle and can start a new task after completion.
comments. DM access settings and task controls still apply to threads inside DMs.
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
saved task. Earlier tasks remain separate; this change does not merge history
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
thread replies, including queued work. DMs are exempt from mention checks;
endpoint availability, **Allow direct messages**, and sender authorization still
apply. Top-level DM replies retain their linear conversation destination. Native
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:
apply. Top-level DM replies retain their linear conversation destination. Source:
[admission](../../server/src/services/chat-channels.ts) and
[queued run policy](../../server/src/services/durable-chat-wakeup.ts). Live Slack
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
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
still require a mention when that setting is enabled. Finish a task inside a DM
thread, then reply in that same thread. The reply must reopen the same task and
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.
top-level replies to top-level DMs. Channel messages and channel thread replies still
require a mention when that setting is enabled. Disable direct messages and
confirm a plain DM is rejected. Threads also work in DMs.
For Slack acknowledgement latency, mention the bot with and without an attachment.
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 { callbacks, endpoint, runtime, service } =
await configuredSlackEndpoint(fixture);
@@ -58564,7 +58564,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
threadId: starterThreadId,
text: expect.stringMatching(/— todo$/),
});
await invoke("close");
await invoke(control);
await expect(service.listConversations(endpoint.id)).resolves.toEqual([
expect.objectContaining({
externalThreadId: starterThreadId,
@@ -58578,9 +58578,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
state: "completed",
}),
]);
expect(
await readIssuesByCompanyId(fixture.companyId),
).toHaveLength(1);
expect(await readIssuesByCompanyId(fixture.companyId)).toHaveLength(1);
const providerTime = Date.now();
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 () => {
+6 -2
View File
@@ -1749,13 +1749,17 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
let { conversation: existingConversation, issue: existingIssue, latestConversation, isLinear } =
await resolveInboundConversationBinding(db, {
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"
? () => conversationForThread(endpoint.id, thread.id)
: undefined,
conversationIsComplete: endpoint.provider === "imessage-photon"
? (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"
? telegramInboundControls(message.text, surfaceKind)
@@ -9,7 +9,7 @@ type ConversationBindingInput = {
thread: { id: string; channelId: string };
isLinear: () => boolean;
lookupConversation?: () => Promise<ConversationRow | null>;
conversationIsComplete?: (conversationId: string) => Promise<boolean>;
conversationIsComplete?: (conversationId: string, state: ConversationRow["state"]) => Promise<boolean>;
};
export async function resolveInboundConversationBinding(
@@ -54,7 +54,7 @@ export async function resolveInboundConversationBinding(
isLinear &&
existingConversation &&
(conversationIsComplete
? await conversationIsComplete(existingConversation.id)
? await conversationIsComplete(existingConversation.id, existingConversation.state)
: existingConversation.state === "completed" ||
existingIssue?.status === "done" ||
existingIssue?.status === "cancelled")