Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slack-thread-unsubscribe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Added Slack `threadParticipation` configuration for following active threads with arbitrary durable user-defined state. Handlers independently decide whether to respond and which JSON-serializable state is available to the next thread reply.
84 changes: 75 additions & 9 deletions docs/channels/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,90 @@ Only the first available handler runs. A message hook returning `null` drops the

`onInteraction(action, ctx)` separately handles `block_actions` callbacks not consumed by HITL. eve attaches the triggering Slack user id to the same model message as its text, preserving speaker attribution without profile lookups.

#### Continue conversations without repeated mentions
#### Participate in active threads

Use `onMessage` to handle explicit mentions and continue replies in threads with an active eve session:
The API grows from mention-only behavior to automatic or stateful participation.

**1. Respond only to explicit mentions.** This is the default:

```ts title="agent/channels/slack.ts"
export default slackChannel({
credentials: connectSlackCredentials("slack/my-agent"),
async onMessage(ctx, message) {
if (message.author?.isBot) return null;
const isDirectMessage = message.raw.channel_type === "im";
return isDirectMessage || ctx.isBotMentioned() || (await ctx.isSubscribed())
? { auth: null }
: null;
});
```

**2. Respond to every human reply in active threads.** Enable `threadParticipation` without requiring another mention:

```ts title="agent/channels/slack.ts"
export default slackChannel({
credentials: connectSlackCredentials("slack/my-agent"),
threadParticipation: true,
});
```

For Vercel Connect, open **Advanced** when creating the connector and add `message.channels` under **Trigger Event Types** and `channels:history` under **Bot Scopes**. Private channels additionally need `message.groups` and `groups:history`.

**3. Track your own participation state.** Provide an `initialState` and return the next state from `handle`. This example treats mentioning another person as a handoff:

```ts title="agent/channels/slack.ts"
type ParticipationState = "followingThread" | "ignoringThread";

export default slackChannel({
credentials: connectSlackCredentials("slack/my-agent"),
threadParticipation: {
initialState: "followingThread" as ParticipationState,
handle(message, ctx) {
if (ctx.isBotMentioned) return { respond: true, state: "followingThread" };
if (ctx.state === "ignoringThread") return { respond: false, state: ctx.state };

const mentionsSomeone = /<@[A-Z0-9]+(?:\|[^>]+)?>/.test(message.text);
return mentionsSomeone
? { respond: false, state: "ignoringThread" }
: { respond: true, state: ctx.state };
},
},
});
```

The handler runs for replies in a thread, including explicit mentions, but not for the top-level message that starts the thread. It runs inside the durable session after Slack channel state and `defineState` are hydrated. `state` must be JSON-serializable.

**4. Classify ambiguous replies.** The same state can distinguish a permanent handoff from one ignored message:

```ts title="agent/channels/slack.ts"
import { generateText, Output } from "ai";
import { z } from "zod";

type ParticipationState = "followingThread" | "ignoringThread";

export default slackChannel({
credentials: connectSlackCredentials("slack/my-agent"),
threadParticipation: {
initialState: "followingThread" as ParticipationState,
async handle(message, ctx) {
if (ctx.isBotMentioned) return { respond: true, state: "followingThread" };
if (ctx.state === "ignoringThread") return { respond: false, state: ctx.state };

await ctx.thread.refresh();
const conversation = ctx.thread.recentMessages
.map((entry) => `${entry.isMe ? "agent" : (entry.user ?? "unknown")}: ${entry.text}`)
.join("\n");
const { output } = await generateText({
model: "google/gemini-3.1-flash-lite",
system: "Decide whether the final Slack message expects the agent to answer.",
prompt: conversation,
output: Output.object({ schema: z.object({ respond: z.boolean() }) }),
timeout: 1_500,
maxRetries: 0,
});
return { respond: output.respond, state: ctx.state };
},
},
});
```

`isBotMentioned()` identifies an explicit mention. `isSubscribed()` checks whether the message belongs to a thread with an active eve session. For Vercel Connect, open **Advanced** when creating the connector and add `message.channels` under **Trigger Event Types** and `channels:history` under **Bot Scopes**. Private channels additionally need `message.groups` and `groups:history`.
`ctx.thread.refresh()` loads up to 50 Slack messages into `ctx.thread.recentMessages`. AI SDK string model ids use AI Gateway and the same ambient `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` credentials as string models in `defineAgent`; the standalone call does not add classifier input to agent history.

Use `onMessage` only when you need lower-level webhook-side admission, custom auth, or pre-dispatch behavior. `ctx.isBotMentioned()` identifies explicit mentions, while `ctx.isSubscribed()` is a point-in-time check for an active eve session; it does not inspect `threadParticipation` state.

Use `ctx.thread.listParticipants()` when routing depends on who has joined the thread. It fetches the current thread and returns unique human Slack user ids in first-appearance order, so the first id is the starting author for a human-started thread. Bot and system messages are excluded:

Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/channel/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface RouteHandlerArgs<TState = undefined> {
export interface SendPayload {
readonly message?: string | UserContent;
readonly inputResponses?: readonly InputResponse[];
/** @internal Channel-owned serializable data for durable delivery processing. */
readonly channelData?: unknown;
/**
* Context strings contributed by the channel. eve appends each entry
* as a `role: "user"` message to `session.history` before the delivery
Expand Down
20 changes: 20 additions & 0 deletions packages/eve/src/channel/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ describe("createSendFn", () => {
});
});

it("forwards channel-owned delivery data through deliver and run inputs", async () => {
const channelData = { kind: "test-message", value: 1 };
const deliverRuntime = createRuntime(new RuntimeNoActiveSessionError("test:token"));
vi.mocked(deliverRuntime.deliver).mockResolvedValue({ sessionId: "existing-session-id" });

const deliverSend = createSendFn(deliverRuntime, ADAPTER, "test");
await deliverSend(
{ channelData, message: "hello" },
{ auth: null, continuationToken: "token" },
);
expect(vi.mocked(deliverRuntime.deliver).mock.calls[0]?.[0].payload.channelData).toEqual(
channelData,
);

const runRuntime = createRuntime(new RuntimeNoActiveSessionError("test:token"));
const runSend = createSendFn(runRuntime, ADAPTER, "test");
await runSend({ channelData, message: "hello" }, { auth: null, continuationToken: "token" });
expect(vi.mocked(runRuntime.run).mock.calls[0]?.[0].input.channelData).toEqual(channelData);
});

it("adds channel request ids to deliver and run inputs when provided", async () => {
const deliverRuntime: Runtime = {
cancelTurn: vi.fn(),
Expand Down
5 changes: 3 additions & 2 deletions packages/eve/src/channel/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function createSendFn<TState = undefined>(
const {
message: rawMessage,
inputResponses,
channelData,
context,
outputSchema,
} = normalizeSendInput(input);
Expand All @@ -39,7 +40,7 @@ export function createSendFn<TState = undefined>(
auth,
continuationToken,
requestId: metadata.requestId,
payload: { inputResponses, message, context, outputSchema },
payload: { inputResponses, channelData, message, context, outputSchema },
};
const { sessionId } = await runtime.deliver(deliverInput);

Expand Down Expand Up @@ -69,7 +70,7 @@ export function createSendFn<TState = undefined>(
channelName,
callback,
continuationToken,
input: { message: message ?? "", context, outputSchema },
input: { channelData, message: message ?? "", context, outputSchema },
mode,
requestId: metadata.requestId,
title,
Expand Down
2 changes: 2 additions & 0 deletions packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ export interface RunInput {
readonly initiatorAuth?: SessionAuthContext | null;
readonly input: {
readonly message: string | UserContent;
/** @internal Channel-owned serializable data for durable delivery processing. */
readonly channelData?: unknown;
readonly context?: readonly string[];
readonly outputSchema?: JsonObject;
};
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/execution/workflow-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise<Workflow
kind: "deliver",
payloads: [
{
channelData: input.input.channelData,
message: input.input.message,
context: input.input.context,
outputSchema: input.input.outputSchema,
Expand Down
43 changes: 28 additions & 15 deletions packages/eve/src/execution/workflow-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
SessionDynamicToolRuntimeRevisionKey,
} from "#context/keys.js";
import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js";
import { runStep } from "#context/run-step.js";
import { runStep, withContextScope } from "#context/run-step.js";
import { deserializeContext, serializeContext } from "#context/serialize.js";
import { getHarnessEmissionState } from "#harness/emission.js";
import {
Expand Down Expand Up @@ -209,7 +209,7 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu
ctx.set(AuthKey, input.input.auth ?? null);
}

const initialSession = hydrateDurableSession({
let initialSession = hydrateDurableSession({
compactionOverrides: {
thresholdPercent: bundle.resolvedAgent.config.compaction?.thresholdPercent,
},
Expand All @@ -219,21 +219,34 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu

const adapterCtx = buildAdapterContext(adapter, ctx);

// Run the adapter's deliver hook for each queued payload and
// coalesce the resulting StepInput values.
// Run the adapter's deliver hook for each queued payload inside the same
// hydrated ALS scope as tools and event handlers. Durable channel policies
// can therefore use SessionContext, defineState, sandbox access, and other
// context-backed APIs before deciding whether the delivery starts a turn.
let resolved: StepInput | undefined;
let deliverySessionChanged = false;
if (input.input?.kind === "deliver") {
const results: StepInput[] = [];
for (const payload of input.input.payloads) {
const result = adapter.deliver
? await adapter.deliver(payload, adapterCtx)
: defaultDeliverResult(payload);

if (result !== undefined && result !== null) {
results.push(result);
const delivery = input.input;
const beforeDeliverySession = initialSession;
const scoped = await withContextScope(ctx, initialSession, async (enrichedSession) => {
const results: StepInput[] = [];
for (const payload of delivery.payloads) {
const result = adapter.deliver
? await adapter.deliver(payload, adapterCtx)
: defaultDeliverResult(payload);

if (result !== undefined && result !== null) {
results.push(result);
}
}
}
resolved = results.length === 0 ? undefined : results.reduce(coalesceTurnInputs);
return {
result: results.length === 0 ? undefined : results.reduce(coalesceTurnInputs),
session: enrichedSession,
};
});
resolved = scoped.result;
initialSession = scoped.session;
deliverySessionChanged = initialSession !== beforeDeliverySession;
} else if (input.input?.kind === "runtime-action-result") {
recordSubagentUsageSpans(input.input.results);
resolved = { runtimeActionResults: input.input.results };
Expand All @@ -253,7 +266,7 @@ export async function turnStep(rawInput: TurnStepInput): Promise<DurableStepResu
const rekeyed = reconcileSessionContinuationToken(ctx, initialSession);
const nextSerializedContext = serializeContext(ctx);
const nextState =
rekeyed === initialSession
rekeyed === initialSession && !deliverySessionChanged
? input.sessionState
: createDurableSessionState({ session: rekeyed });

Expand Down
3 changes: 3 additions & 0 deletions packages/eve/src/public/channels/slack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export {
type SlackResetOptions,
type SlackSessionTarget,
type SlackThread,
type SlackThreadParticipation,
type SlackThreadParticipationContext,
type SlackThreadParticipationResult,
type SlackWebhookVerifier,
type SlackWorkspaceHandle,
} from "#public/channels/slack/slackChannel.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/public/channels/slack/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ function hasCardContent(block: Record<string, unknown>): boolean {
* `onInteraction` callback for non-HITL clicks.
*/
export interface InteractionHandlerDeps {
readonly config: SlackChannelConfig;
readonly config: SlackChannelConfig<unknown>;
}

/**
Expand Down
Loading
Loading