mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
fix(slack): preserve explicit DM task controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
78108efc0c
commit
367b6f9b3f
6 files changed
+33
-26
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user