Skip to content
5 changes: 5 additions & 0 deletions .changeset/private-slack-approvals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Slack channels can route each tool approval to the shared thread or the triggering user's direct messages with the `approvalChannel` callback. Direct-message approvals start with a forwarded-message preview of the Slack message that triggered the turn, while the original thread names the reviewer without exposing the tool input. The approval card updates after the approval settles.
13 changes: 13 additions & 0 deletions docs/channels/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@ export default slackChannel({

HITL renders as Slack buttons and selects. Fixed-choice actions and freeform modal submissions pass through `onInputResponse` before the parked session resumes. The initial **Type your answer** action only opens eve's modal; the hook runs when the user submits an answer.

Set `approvalChannel` to choose where each tool approval appears. Return `"direct-message"` when the tool input must be visible only to the Slack user who triggered the turn, or `"thread"` for the normal shared-thread approval.

```ts title="agent/channels/slack.ts"
import { slackChannel } from "eve/channels/slack";

export default slackChannel({
approvalChannel: (request) =>
request.action.toolName === "review_answer" ? "direct-message" : "thread",
});
```

The callback receives the approval `InputRequest` and current `SessionContext`. Omit it to keep every approval in the thread, or return `"direct-message"` unconditionally to make every tool approval private. Questions and session-limit prompts always remain in the session thread. If eve cannot resolve the triggering Slack user for a direct message, it logs the undelivered request and posts no fallback, so private approval fails closed. The existing `onInputResponse` or tool approval response policy still decides whether the person who clicks may approve.

Authorization prompts split public status from private credentials. A sign-in challenge (OAuth URL, device code) is a credential. Anyone who completes it binds their identity to the session's connection. The default `authorization.required` handler posts a public, link-free status in the thread, delivers the actual challenge ephemerally to the triggering user, device code included, and then updates that public status when `authorization.completed` fires. The handler receives a private-delivery context with `postEphemeral`, `postDirectMessage` (needs the `im:write` scope), and `state`. There is, intentionally, no public `post` and no raw API access.

```ts
Expand Down
206 changes: 199 additions & 7 deletions packages/eve/src/public/channels/slack/defaults.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";

import type { SessionContext } from "#public/definitions/callback-context.js";
import { defaultEvents } from "#public/channels/slack/defaults.js";
import { defaultEvents, defaultInputRequestedHandler } from "#public/channels/slack/defaults.js";
import type { SlackChannelState, SlackEventContext } from "#public/channels/slack/slackChannel.js";

function sessionContext(
Expand All @@ -20,22 +20,63 @@ function sessionContext(

const sessionCtx = sessionContext();

function approvalRequest(requestId = "approval-1") {
return {
action: {
callId: "call-1",
input: { answer: "private draft" },
kind: "tool-call" as const,
toolName: "review_answer",
},
allowFreeform: false,
display: "confirmation" as const,
kind: "tool-approval" as const,
options: [
{ id: "approve", label: "Approve", style: "primary" as const },
{ id: "cancel", label: "Cancel", style: "danger" as const },
],
prompt: "Approve review_answer?",
requestId,
};
}

function buildChannelStub(state: Partial<SlackChannelState> = {}) {
const postEphemeral = vi.fn().mockResolvedValue({ id: "eph1" });
const post = vi.fn().mockResolvedValue({ id: "ts1" });
const postEphemeral = vi.fn().mockResolvedValue({ id: "eph1", raw: { ok: true } });
const postDirectMessage = vi
.fn()
.mockResolvedValue({ id: "dm1", raw: { channel: "D123", ok: true } });
const post = vi.fn().mockResolvedValue({ id: "ts1", raw: { ok: true } });
const startTyping = vi.fn().mockResolvedValue(undefined);
const request = vi.fn().mockResolvedValue({ ok: true });
let postedMessages = 0;
const request = vi.fn(async (operation: string, _body: unknown) => {
if (operation === "conversations.open") return { channel: { id: "D123" }, ok: true };
if (operation === "chat.getPermalink") {
return {
ok: true,
permalink: "https://example.slack.com/archives/C123/p111333?thread_ts=111.222&cid=C123",
};
}
if (operation === "chat.postMessage") {
postedMessages += 1;
return { ok: true, ts: `dm${postedMessages}` };
}
return { ok: true };
});
const channel = {
thread: { postEphemeral, post, startTyping } as Partial<SlackEventContext["thread"]>,
slack: { channelId: "C123", request } as Partial<SlackEventContext["slack"]>,
thread: { postDirectMessage, postEphemeral, post, startTyping } as Partial<
SlackEventContext["thread"]
>,
slack: { channelId: "C123", request, threadTs: "111.222" } as Partial<
SlackEventContext["slack"]
>,
state: {
channelId: "C123",
threadTs: "111.222",
teamId: null,
...state,
},
} as SlackEventContext;
return { channel, post, postEphemeral, request, startTyping };
return { channel, post, postDirectMessage, postEphemeral, request, startTyping };
}

function authRequiredEvent(
Expand All @@ -51,6 +92,157 @@ function authRequiredEvent(
};
}

describe("defaultInputRequestedHandler private tool approvals", () => {
it("uses the authored destination for a tool approval", async () => {
const { channel, post, postDirectMessage } = buildChannelStub();
const approvalChannel = vi.fn(() => "thread" as const);

await defaultInputRequestedHandler(approvalChannel)(
{ requests: [approvalRequest()], sequence: 1, stepIndex: 0, turnId: "turn-1" },
channel,
sessionCtx,
);

expect(approvalChannel).toHaveBeenCalledWith(
expect.objectContaining({ requestId: "approval-1" }),
sessionCtx,
);
expect(post).toHaveBeenCalled();
expect(postDirectMessage).not.toHaveBeenCalled();
});

it("fails closed when no direct-message reviewer can be resolved", async () => {
const { channel, post, postDirectMessage, postEphemeral } = buildChannelStub();

await defaultInputRequestedHandler(() => "direct-message")(
{ requests: [approvalRequest()], sequence: 1, stepIndex: 0, turnId: "turn-1" },
channel,
sessionCtx,
);

expect(post).not.toHaveBeenCalled();
expect(postDirectMessage).not.toHaveBeenCalled();
expect(postEphemeral).not.toHaveBeenCalled();
});

it("rolls back partial DM delivery without announcing an unusable approval", async () => {
const { channel, post, request } = buildChannelStub({
triggeringMessageTs: "111.333",
triggeringUserId: "U_REVIEWER",
});
let postedMessages = 0;
request.mockImplementation(async (operation: string) => {
if (operation === "conversations.open") return { channel: { id: "D123" }, ok: true };
if (operation === "chat.getPermalink") {
return { ok: true, permalink: "https://example.slack.com/archives/C123/p111333" };
}
if (operation === "chat.postMessage") {
postedMessages += 1;
return postedMessages < 3
? { ok: true, ts: `dm${postedMessages}` }
: { error: "message_failed", ok: false };
}
return { ok: true };
});

await expect(
defaultInputRequestedHandler(() => "direct-message")(
{ requests: [approvalRequest()], sequence: 1, stepIndex: 0, turnId: "turn-1" },
channel,
sessionCtx,
),
).rejects.toThrow("Slack chat.postMessage failed: message_failed");

expect(post).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("chat.delete", { channel: "D123", ts: "dm1" });
expect(request).toHaveBeenCalledWith("chat.delete", { channel: "D123", ts: "dm2" });
expect(channel.state.pendingApprovalCards).toBeUndefined();
});

it("keeps the actionable DM committed when its thread announcement fails", async () => {
const { channel, post, request } = buildChannelStub({
triggeringMessageTs: "111.333",
triggeringUserId: "U_REVIEWER",
});
post.mockRejectedValueOnce(new Error("thread unavailable"));

await expect(
defaultInputRequestedHandler(() => "direct-message")(
{ requests: [approvalRequest()], sequence: 1, stepIndex: 0, turnId: "turn-1" },
channel,
sessionCtx,
),
).resolves.toBeUndefined();

expect(
request.mock.calls.filter(([operation]) => operation === "chat.postMessage"),
).toHaveLength(3);
expect(channel.state.pendingApprovalCards?.["approval-1"]).toMatchObject({
messageChannelId: "D123",
messageTs: "dm3",
});
});

it("previews the triggering message and updates the routed DM card after settlement", async () => {
const { channel, post, postDirectMessage, request } = buildChannelStub({
triggeringMessageTs: "111.333",
triggeringUserId: "U_REVIEWER",
});

await defaultInputRequestedHandler(() => "direct-message")(
{ requests: [approvalRequest()], sequence: 1, stepIndex: 0, turnId: "turn-1" },
channel,
sessionCtx,
);

expect(post).toHaveBeenCalledWith("Waiting on approval from <@U_REVIEWER>…");
expect(postDirectMessage).not.toHaveBeenCalled();
expect(
request.mock.calls.filter(([operation]) => operation === "conversations.open"),
).toHaveLength(1);
const directMessages = request.mock.calls.filter(
([operation]) => operation === "chat.postMessage",
);
expect(directMessages).toHaveLength(3);
expect(directMessages[0]?.[1]).toMatchObject({
channel: "D123",
markdown_text: "https://example.slack.com/archives/C123/p111333?thread_ts=111.222&cid=C123",
unfurl_links: true,
});
expect(JSON.stringify(directMessages[1]?.[1])).toContain("private draft");
expect(JSON.stringify(directMessages[2]?.[1])).toContain(
"eve_input:route:C123:111.222:tool-approval:approval-1",
);
expect(request).toHaveBeenCalledWith("chat.getPermalink", {
channel: "C123",
message_ts: "111.333",
});
expect(channel.state.pendingApprovalCards?.["approval-1"]?.messageChannelId).toBe("D123");

await defaultEvents["approval.settled"]!(
{
outcome: "approved",
requestId: "approval-1",
responderPrincipalId: "slack:T1:U_REVIEWER",
sequence: 1,
stepIndex: 0,
turnId: "turn-1",
},
channel,
sessionCtx,
);

expect(request).toHaveBeenCalledWith(
"chat.update",
expect.objectContaining({ channel: "D123", text: "Answered: Approve", ts: "dm3" }),
);
const update = request.mock.calls.find(([method]) => method === "chat.update")?.[1] as {
blocks?: unknown[];
};
expect(JSON.stringify(update.blocks)).not.toContain("eve_input:route:");
});
});

describe("defaultEvents approval lifecycle", () => {
it("sends candidate progress privately", async () => {
const { channel, postEphemeral } = buildChannelStub({
Expand Down
Loading