[1/5] feat(slack): render activity messages (#2310)

Signed-off-by: benpankow <ben.pankow@vercel.com>
This commit is contained in:
Ben Pankow
2026-09-03 15:07:33 -07:00
committed by GitHub
parent 38fd8d9bad
commit 30a63258f1
20 changed files with 1668 additions and 92 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Allow Slack channels to register experimental custom activity renderers that consume activity snapshots and retain renderer-owned state between updates.
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Add experimental Unicode tree and native streaming plan Slack activity renderers. The plan keeps stable top-level tasks, appends descendant lifecycle updates, and compacts completed streams to top-level work.
@@ -20,6 +20,32 @@ export function deriveRootTurnWorkIdentity(session: Session): ActivityWorkIdenti
};
}
export function deriveBackgroundTaskActivityObserver(input: {
readonly activityObserver: ActivityObserverConfig | undefined;
readonly callId: string;
readonly name: string;
readonly parentSessionId: string;
readonly parentTurnId: string;
readonly rootSessionId: string;
}): ActivityObserverConfig | undefined {
if (input.activityObserver === undefined) return undefined;
const parentWork = input.activityObserver.workIdentity ?? {
id: deriveRootTurnActivityWorkId({
sessionId: input.parentSessionId,
turnId: input.parentTurnId,
}),
kind: "root-turn" as const,
rootSessionId: input.rootSessionId,
rootTurnId: input.parentTurnId,
sessionId: input.parentSessionId,
turnId: input.parentTurnId,
};
return {
sink: input.activityObserver.sink,
workIdentity: deriveChildWorkIdentity({ ...input, kind: "task", parentWork }),
};
}
export function deriveChildActivityObserverConfig(input: {
readonly callId: string;
readonly kind: Exclude<ActivityWorkKind, "root-turn">;
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ActivityObserverKey } from "#context/keys.js";
import { ActivityObserverKey, TurnTaskDeliveryKey } from "#context/keys.js";
import { ContextContainer } from "#context/container.js";
import {
observeSessionActivity,
@@ -174,7 +174,7 @@ describe("projectSessionActivity", () => {
describe("observeSessionActivity", () => {
afterEach(() => vi.unstubAllGlobals());
function context(): ContextContainer {
function context(taskDelivery?: "none" | "initiating" | "pending" | "settled"): ContextContainer {
const ctx = new ContextContainer();
ctx.set(ActivityObserverKey, {
sink: {
@@ -182,6 +182,7 @@ describe("observeSessionActivity", () => {
version: 1,
},
});
if (taskDelivery !== undefined) ctx.set(TurnTaskDeliveryKey, taskDelivery);
return ctx;
}
@@ -204,6 +205,34 @@ describe("observeSessionActivity", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not project internal background-task delivery turns as new root work", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
for (const taskDelivery of ["pending", "settled"] as const) {
await observeSessionActivity({
ctx: context(taskDelivery),
event: turnEvent("turn.started", `turn-${taskDelivery}`),
sessionId: "session-1",
});
}
expect(fetchMock).not.toHaveBeenCalled();
});
it("keeps projecting the turn that initiates background tasks", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
await observeSessionActivity({
ctx: context("initiating"),
event: turnEvent("turn.started"),
sessionId: "session-1",
});
expect(fetchMock).toHaveBeenCalledOnce();
});
it("submits projected activity and swallows transport failure", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
vi.stubGlobal("fetch", fetchMock);
@@ -1,5 +1,5 @@
import type { ContextContainer } from "#context/container.js";
import { ActivityObserverKey } from "#context/keys.js";
import { ActivityObserverKey, TurnTaskDeliveryKey } from "#context/keys.js";
import { projectActivityEvents } from "#execution/activity-events.js";
import { deriveRootTurnActivityWorkId } from "#execution/activity-work-id.js";
import { submitActivity } from "#execution/submit-activity.js";
@@ -14,6 +14,12 @@ export async function observeSessionActivity(input: {
}): Promise<void> {
const observer = input.ctx.get(ActivityObserverKey);
if (observer === undefined) return;
const taskDelivery = input.ctx.get(TurnTaskDeliveryKey);
if (
observer.workIdentity === undefined &&
(taskDelivery === "pending" || taskDelivery === "settled")
)
return;
await submitActivity({
events: projectSessionActivity({
event: input.event,
@@ -217,6 +217,90 @@ describe("activity protocol and reducer", () => {
expect(snapshot.blockers["child-input"]?.phase).toBe("cancelled");
});
it("keeps background task subtrees active when their initiating turn settles", () => {
const task = {
callId: "call-task",
id: "work:task",
kind: "subagent" as const,
parentId: work.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const child = {
id: "work:task-child",
kind: "subagent" as const,
parentId: task.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const snapshot = reduce([
{ eventId: "root", kind: "work.started", startedAt: "1", work },
{
action: {
id: `action:${work.id}:call-task`,
kind: "tool",
name: "researcher",
parentWorkId: work.id,
rootTurnId: "turn",
stepIndex: 0,
},
eventId: "task-action",
kind: "action.started",
startedAt: "2",
},
{ eventId: "task", kind: "work.started", startedAt: "2", work: task },
{ eventId: "task-child", kind: "work.started", startedAt: "3", work: child },
{
eventId: "root-settled",
kind: "work.settled",
outcome: "completed",
settledAt: "4",
workId: work.id,
},
]);
expect(snapshot.work[work.id]?.phase).toBe("completed");
expect(snapshot.work[task.id]?.phase).toBe("running");
expect(snapshot.work[child.id]?.phase).toBe("running");
});
it("starts background task work even when its parent turn already settled", () => {
const task = {
callId: "call-task",
id: "work:task",
kind: "subagent" as const,
parentId: work.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const snapshot = reduce([
{ eventId: "root", kind: "work.started", startedAt: "1", work },
{
action: {
id: `action:${work.id}:call-task`,
kind: "tool",
name: "researcher",
parentWorkId: work.id,
rootTurnId: "turn",
stepIndex: 0,
},
eventId: "task-action",
kind: "action.started",
startedAt: "1",
},
{
eventId: "root-settled",
kind: "work.settled",
outcome: "completed",
settledAt: "2",
workId: work.id,
},
{ eventId: "task", kind: "work.started", startedAt: "3", work: task },
]);
expect(snapshot.work[task.id]?.phase).toBe("running");
});
it("cancels running owned actions and blockers when work settles", () => {
const snapshot = reduce([
{ eventId: "work", kind: "work.started", startedAt: "1", work },
+20 -2
View File
@@ -83,7 +83,11 @@ function startWork(
const parent = event.work.parentId === undefined ? undefined : snapshot.work[event.work.parentId];
const phase =
pending?.outcome ??
(parent !== undefined && parent.phase !== "running" ? "cancelled" : "running");
(!isBackgroundWorkBoundary(snapshot, event.work) &&
parent !== undefined &&
parent.phase !== "running"
? "cancelled"
: "running");
const work: ActivityWorkStateV1 = {
...event.work,
name: event.work.name === undefined ? undefined : normalizeActivityText(event.work.name),
@@ -250,7 +254,12 @@ function settleWorkTree(
while (discovered) {
discovered = false;
for (const work of Object.values(snapshot.work)) {
if (work.parentId === undefined || !subtree.has(work.parentId) || subtree.has(work.id))
if (
work.parentId === undefined ||
!subtree.has(work.parentId) ||
subtree.has(work.id) ||
isBackgroundWorkBoundary(snapshot, work)
)
continue;
subtree.add(work.id);
discovered = true;
@@ -280,6 +289,15 @@ function settleWorkTree(
};
}
function isBackgroundWorkBoundary(
snapshot: ActivitySnapshotV1,
work: ActivityWorkStateV1 | Extract<ActivityEventV1, { readonly kind: "work.started" }>["work"],
): boolean {
if (work.kind === "task") return true;
if (work.callId === undefined || work.parentId === undefined) return false;
return snapshot.actions[`action:${work.parentId}:${work.callId}`] !== undefined;
}
function mapActivityStates<T>(
values: Readonly<Record<string, T>>,
transform: (value: T) => T,
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
deliverTaskInputResponsesStep,
formatTaskNotification,
projectTaskActivity,
wakeTaskAgentRequestParentStep,
} from "#execution/tasks/child/steps.js";
import { resumeWorkflowToolRunAnswers } from "#execution/tools/workflow/answer.js";
@@ -54,6 +55,74 @@ const notificationCases: readonly { readonly expected: string; readonly view: Ta
},
];
describe("projectTaskActivity", () => {
afterEach(() => vi.unstubAllGlobals());
it("projects terminal task settlement", () => {
expect(
projectTaskActivity({
activityObserver: {
sink: {
url: "https://parent.example/eve/v1/activity/abcdefghijklmnopqrstuvwxyz123456",
version: 1,
},
workIdentity: {
id: "work:task",
kind: "task",
rootSessionId: "root",
rootTurnId: "turn",
},
},
settledAt: "2026-01-01T00:00:00.000Z",
view: notificationCases[0]!.view,
}),
).toEqual([
expect.objectContaining({ kind: "work.settled", outcome: "completed", workId: "work:task" }),
]);
});
it("projects task work when its initial view is written", () => {
const workIdentity = {
id: "work:task",
kind: "task" as const,
name: "export",
parentId: "work:root",
rootSessionId: "root",
rootTurnId: "turn",
};
expect(
projectTaskActivity({
activityObserver: {
sink: {
url: "https://parent.example/eve/v1/activity/abcdefghijklmnopqrstuvwxyz123456",
version: 1,
},
workIdentity,
},
settledAt: "2026-01-01T00:00:00.000Z",
view: { metadata, status: "working", taskId: "task-1" },
}),
).toEqual([
{
eventId: "work:task:started",
kind: "work.started",
startedAt: "2026-01-01T00:00:00.000Z",
work: workIdentity,
},
]);
});
it("does nothing without activity observation", () => {
expect(
projectTaskActivity({
activityObserver: undefined,
settledAt: "2026-01-01T00:00:00.000Z",
view: { metadata, status: "working", taskId: "task-1" },
}),
).toEqual([]);
});
});
describe("formatTaskNotification", () => {
it.each(notificationCases)(
"includes terminal output in the parent notification",
@@ -1,15 +1,21 @@
import { getWritable } from "#compiled/@workflow/core/index.js";
import type { SessionAuthContext, SessionCommand } from "#channel/types.js";
import type {
ActivityObserverConfig,
SessionAuthContext,
SessionCommand,
} from "#channel/types.js";
import type {
WorkflowToolAuthorizationRequest,
WorkflowToolRunRequestMessage,
} from "#execution/tools/workflow/messages.js";
import type { WorkflowToolRunTaskInputRequest } from "./workflow.js";
import { submitActivity } from "#execution/submit-activity.js";
import { isTaskWorkflowTargetGone } from "#execution/tasks/workflow-target.js";
import { resumeSessionInbox } from "#execution/wire/session-inbox-resume.js";
import { resumeWorkflowToolRunAnswers } from "#execution/tools/workflow/answer.js";
import type { AnswerHookRoute } from "#harness/proxy-input-requests.js";
import { createLogger } from "#internal/logging.js";
import type { ActivityEventV1 } from "#protocol/activity.js";
import type { JsonValue } from "#shared/json.js";
import {
isTerminalTaskStatus,
@@ -30,7 +36,10 @@ const log = createLogger("execution.tasks.run");
* stream. Only the task run workflow calls this, which is what makes
* the run the single writer readers can trust without re-validating.
*/
export async function appendTaskViewStep(input: { readonly view: TaskView }): Promise<void> {
export async function appendTaskViewStep(input: {
readonly activityObserver?: ActivityObserverConfig;
readonly view: TaskView;
}): Promise<void> {
"use step";
const writable = getWritable<TaskView>({ namespace: TASK_VIEW_STREAM_NAMESPACE });
@@ -40,6 +49,43 @@ export async function appendTaskViewStep(input: { readonly view: TaskView }): Pr
} finally {
writer.releaseLock();
}
const events = projectTaskActivity({
activityObserver: input.activityObserver,
settledAt: new Date().toISOString(),
view: input.view,
});
void submitActivity({ events, sink: input.activityObserver?.sink });
}
export function projectTaskActivity(input: {
readonly activityObserver: ActivityObserverConfig | undefined;
readonly settledAt: string;
readonly view: TaskView;
}): readonly ActivityEventV1[] {
const work = input.activityObserver?.workIdentity;
if (work === undefined) return [];
const status = input.view.status;
if (status === "working") {
return [
{
eventId: `${work.id}:started`,
kind: "work.started",
startedAt: input.settledAt,
work,
},
];
}
if (status !== "completed" && status !== "failed" && status !== "cancelled") return [];
return [
{
eventId: `${work.id}:settled:${status}`,
kind: "work.settled",
outcome: status,
settledAt: input.settledAt,
workId: work.id,
},
];
}
/**
@@ -1,5 +1,6 @@
import { createHook } from "#compiled/@workflow/core/index.js";
import type { ActivityObserverConfig } from "#channel/types.js";
import { claimHookOwnership, disposeHook, isHookConflictError } from "#execution/hook-ownership.js";
import {
appendTaskViewStep,
@@ -41,6 +42,7 @@ import {
} from "#tasks/types.js";
export interface TaskRunWorkflowInput {
readonly activityObserver?: ActivityObserverConfig;
readonly initialView: TaskView;
readonly parentContinuationToken: string;
readonly taskInboxToken: string;
@@ -103,7 +105,7 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise<void
throw error;
}
await appendTaskViewStep({ view });
await appendTaskViewStep({ activityObserver: input.activityObserver, view });
while (!isFinished()) {
const read = await raceChannelReads(
bodyReader === undefined ? readers : [...readers, bodyReader],
@@ -244,7 +246,7 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise<void
const result = applyTaskTransition(view, command);
if (result.action !== "accepted") return;
view = result.view;
await appendTaskViewStep({ view });
await appendTaskViewStep({ activityObserver: input.activityObserver, view });
if (command.kind === "cancel") {
bodyController.abort(new Error(`Task ${view.taskId} was cancelled.`));
if (bodyReader === undefined) executorSettled = true;
@@ -3,6 +3,7 @@
* Task-run transport (start/command/view) lives in `run-parent.ts`, which
* Callers compose these primitives around their own executor policy.
*/
import type { ActivityObserverConfig } from "#channel/types.js";
import type { HarnessSession } from "#harness/types.js";
import {
readLatestTaskView,
@@ -58,6 +59,7 @@ export function prepareBackgroundTask(input: {
/** Starts a lifecycle-only task run for a non-workflow external executor. */
export async function beginBackgroundTask(input: {
readonly activityObserver?: ActivityObserverConfig;
readonly callId: string;
readonly metadata: TaskMetadata;
readonly parentSessionId: string;
@@ -67,6 +69,7 @@ export async function beginBackgroundTask(input: {
}): Promise<BackgroundTask> {
const task = prepareBackgroundTask(input);
await startTaskRun({
activityObserver: input.activityObserver,
taskInboxToken: task.taskInboxToken,
initialView: { metadata: task.metadata, status: "working", taskId: task.taskId },
parentContinuationToken: sessionCommandHookToken(input.session.sessionId),
@@ -0,0 +1,69 @@
import { getDynamicSubagentSelection } from "#context/dynamic-subagent-lifecycle.js";
import type { loadContext } from "#context/container.js";
import { createSubagentReceiptIdentity } from "#execution/tools/subagent/receipt-identity.js";
import { BundleKey } from "#runtime/sessions/runtime-context-keys.js";
import type { JsonObject } from "#shared/json.js";
export interface SubagentTaskProjection {
readonly identity?: ReturnType<typeof createSubagentReceiptIdentity>;
readonly metadata: {
readonly agentId: string;
readonly kind: "subagent";
readonly mode: "local" | "remote";
readonly name: string;
};
readonly receipt: { readonly agentId: string };
}
export function projectSubagentTask(input: {
readonly ctx: ReturnType<typeof loadContext>;
readonly input: JsonObject;
readonly name: string;
readonly nodeId: string;
readonly taskInput: {
readonly callId: string;
readonly parentSessionId: string;
readonly parentTurnId: string;
};
}): SubagentTaskProjection {
const continuation = input.input.agentId;
if (typeof continuation === "string" && continuation.trim() !== "") {
return {
metadata: {
agentId: continuation,
kind: "subagent",
mode: readSubagentTaskMode(input.ctx, input.nodeId),
name: input.name,
},
receipt: { agentId: continuation },
};
}
const identity = createSubagentReceiptIdentity({
callId: input.taskInput.callId,
nodeId: input.nodeId,
parentSessionId: input.taskInput.parentSessionId,
parentTurnId: input.taskInput.parentTurnId,
subagentName: input.name,
});
return {
identity,
metadata: {
agentId: identity.identity.id,
kind: "subagent",
mode: readSubagentTaskMode(input.ctx, input.nodeId),
name: input.name,
},
receipt: { agentId: identity.identity.id },
};
}
function readSubagentTaskMode(
ctx: ReturnType<typeof loadContext>,
nodeId: string,
): "local" | "remote" {
const dynamic = getDynamicSubagentSelection(ctx, nodeId);
if (dynamic !== undefined) return dynamic.kind === "remote" ? "remote" : "local";
const registered = ctx.get(BundleKey)?.subagentRegistry.subagentsByNodeId.get(nodeId);
return registered?.definition.kind === "remote" ? "remote" : "local";
}
@@ -1,5 +1,6 @@
import type { ContextContainer } from "#context/container.js";
import { loadContext } from "#context/container.js";
import { ActivityObserverKey } from "#context/keys.js";
import type { FrameworkContextProvider } from "#context/provider.js";
import { runStep } from "#context/run-step.js";
import { buildCallbackContext } from "#context/build-callback-context.js";
@@ -9,13 +10,13 @@ import { activeTurnId } from "#harness/active-turn-id.js";
import { getHarnessEmissionState } from "#harness/emission.js";
import { isTurnCancellation } from "#harness/turn-cancellation.js";
import type { HarnessSession, StepResult } from "#harness/types.js";
import { BundleKey } from "#runtime/sessions/runtime-context-keys.js";
import {
BackgroundToolExecutorKey,
type BackgroundExecutableTool,
type BackgroundToolCallBatch,
type BackgroundToolExecutor,
} from "#harness/background-tools.js";
import { deriveBackgroundTaskActivityObserver } from "#execution/activity-work.js";
import { createEveCallbackRoutePath } from "#protocol/routes.js";
import { isAsyncIterable } from "#shared/async-iterable.js";
import { parseJsonValue } from "#shared/json.js";
@@ -44,9 +45,7 @@ import {
waitForTaskCommandOwner,
} from "#execution/tasks/parent/run-parent.js";
import { sessionCommandHookToken } from "#execution/session-command-token.js";
import { createSubagentReceiptIdentity } from "#execution/tools/subagent/receipt-identity.js";
import { parseJsonObject } from "#shared/json.js";
import { getDynamicSubagentSelection } from "#context/dynamic-subagent-lifecycle.js";
import { projectSubagentTask } from "#execution/tasks/parent/subagent-task-projection.js";
import { deriveAgentOperationId } from "#subagents/handles/operation-id.js";
import { AGENT_BUSY, AGENT_MISMATCH, AGENT_UNREACHABLE } from "#subagents/agent-handle-errors.js";
import { formatAgentBusyMessage } from "#subagents/agent-handle-errors.js";
@@ -58,21 +57,8 @@ import {
} from "#subagents/handles/store.js";
import { applyTaskAgentHandleCommand } from "#subagents/handles/transitions.js";
type SubagentReceiptIdentity = ReturnType<typeof createSubagentReceiptIdentity>;
const IN_PROCESS_WORKFLOW_EXECUTOR = { data: {}, kind: "workflow-task" } as const;
interface SubagentTaskProjection {
readonly identity?: SubagentReceiptIdentity;
readonly metadata: {
readonly agentId: string;
readonly kind: "subagent";
readonly mode: "local" | "remote";
readonly name: string;
};
readonly receipt: { readonly agentId: string };
}
interface BackgroundToolExecutionRecord {
claim?: {
readonly operationId: string;
@@ -400,9 +386,21 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor {
},
});
}
const metadata = subagentProjection?.metadata ?? {
kind: "tool" as const,
name: input.input.definition.name,
};
const taskInput = {
activityObserver: deriveBackgroundTaskActivityObserver({
activityObserver: input.ctx.get(ActivityObserverKey),
callId: input.input.options.toolCallId,
name: metadata.name,
parentSessionId: this.initialSession.sessionId,
parentTurnId,
rootSessionId: this.initialSession.rootSessionId ?? this.initialSession.sessionId,
}),
callId: input.input.options.toolCallId,
metadata: subagentProjection?.metadata ?? { kind: "tool", name: input.input.definition.name },
metadata,
parentSessionId: this.initialSession.sessionId,
parentStepIndex: input.emission.stepIndex,
parentTurnId,
@@ -411,6 +409,7 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor {
if (workflow === undefined) {
return {
task: await beginBackgroundTask({
activityObserver: taskInput.activityObserver,
callId: taskInput.callId,
metadata: taskInput.metadata,
parentSessionId: taskInput.parentSessionId,
@@ -475,6 +474,7 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor {
}
}
await startTaskRun({
activityObserver: taskInput.activityObserver,
initialView: { metadata: task.metadata, status: "working", taskId: task.taskId },
parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId),
taskInboxToken: task.taskInboxToken,
@@ -565,59 +565,6 @@ function hasAgentHandle(session: HarnessSession, agentId: string): boolean {
);
}
function projectSubagentTask(input: {
readonly ctx: ReturnType<typeof loadContext>;
readonly input: ReturnType<typeof parseJsonObject>;
readonly name: string;
readonly nodeId: string;
readonly taskInput: {
readonly callId: string;
readonly parentSessionId: string;
readonly parentTurnId: string;
};
}): SubagentTaskProjection {
const continuation = input.input.agentId;
if (typeof continuation === "string" && continuation.trim() !== "") {
return {
metadata: {
agentId: continuation,
kind: "subagent",
mode: readSubagentTaskMode(input.ctx, input.nodeId),
name: input.name,
},
receipt: { agentId: continuation },
};
}
const identity = createSubagentReceiptIdentity({
callId: input.taskInput.callId,
nodeId: input.nodeId,
parentSessionId: input.taskInput.parentSessionId,
parentTurnId: input.taskInput.parentTurnId,
subagentName: input.name,
});
return {
identity,
metadata: {
agentId: identity.identity.id,
kind: "subagent",
mode: readSubagentTaskMode(input.ctx, input.nodeId),
name: input.name,
},
receipt: { agentId: identity.identity.id },
};
}
function readSubagentTaskMode(
ctx: ReturnType<typeof loadContext>,
nodeId: string,
): "local" | "remote" {
const dynamic = getDynamicSubagentSelection(ctx, nodeId);
if (dynamic !== undefined) return dynamic.kind === "remote" ? "remote" : "local";
const registered = ctx.get(BundleKey)?.subagentRegistry.subagentsByNodeId.get(nodeId);
return registered?.definition.kind === "remote" ? "remote" : "local";
}
function requireExecutionScope(executor: BackgroundToolExecutor): BackgroundToolExecutionScope {
if (!(executor instanceof BackgroundToolExecutionScope)) {
throw new Error("The background tool executor is not owned by the task runtime.");
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createActivitySnapshot, reduceActivityBatch } from "#execution/session-activity.js";
import {
buildSlackActivityRenderers,
experimental_slackActivityPlan,
} from "#public/channels/slack/activity.js";
const root = {
id: "root",
kind: "root-turn" as const,
rootSessionId: "session",
rootTurnId: "turn",
};
const verifier = {
id: "verifier",
kind: "subagent" as const,
name: "verifier",
parentId: root.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const stage = {
id: "stage",
kind: "task" as const,
name: "verify_stage",
parentId: verifier.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const reviewer = {
id: "reviewer",
kind: "subagent" as const,
name: "reviewer",
parentId: root.id,
rootSessionId: "session",
rootTurnId: "turn",
};
const reviewStage = {
id: "review-stage",
kind: "task" as const,
name: "review_stage",
parentId: reviewer.id,
rootSessionId: "session",
rootTurnId: "turn",
};
describe("Slack activity plan", () => {
afterEach(() => vi.unstubAllGlobals());
it("streams descendant lifecycle once, then replaces the final plan with top-level work", async () => {
const requests: Array<{ operation: string; body: URLSearchParams }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const operation = String(url).split("/").at(-1)!;
requests.push({ operation, body: new URLSearchParams(String(init?.body ?? "")) });
return Response.json({ ok: true, ts: "1700.1" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityPlan()],
})[0]!;
const started = reduceActivityBatch(createActivitySnapshot(), {
version: 1,
events: [
{ eventId: "root", kind: "work.started", startedAt: "1", work: root },
{ eventId: "verifier", kind: "work.started", startedAt: "2", work: verifier },
{ eventId: "stage", kind: "work.started", startedAt: "3", work: stage },
],
});
const state = await renderer.render({
destination: { channelId: "C1", threadTs: "T1", teamId: "TEAM", triggeringUserId: "USER" },
snapshot: started,
state: undefined,
});
const expanded = reduceActivityBatch(started, {
version: 1,
events: [
{ eventId: "reviewer", kind: "work.started", startedAt: "3", work: reviewer },
{ eventId: "review-stage", kind: "work.started", startedAt: "3", work: reviewStage },
],
});
const expandedState = await renderer.render({
destination: { channelId: "C1", threadTs: "T1", teamId: "TEAM", triggeringUserId: "USER" },
snapshot: expanded,
state,
});
const settled = reduceActivityBatch(expanded, {
version: 1,
events: [
{
eventId: "stage-done",
kind: "work.settled",
outcome: "completed",
settledAt: "4",
workId: stage.id,
},
{
eventId: "review-stage-done",
kind: "work.settled",
outcome: "completed",
settledAt: "4",
workId: reviewStage.id,
},
{
eventId: "verifier-done",
kind: "work.settled",
outcome: "completed",
settledAt: "5",
workId: verifier.id,
},
{
eventId: "reviewer-done",
kind: "work.settled",
outcome: "completed",
settledAt: "5",
workId: reviewer.id,
},
{
eventId: "root-done",
kind: "work.settled",
outcome: "completed",
settledAt: "6",
workId: root.id,
},
],
});
await renderer.render({
destination: { channelId: "C1", threadTs: "T1", teamId: "TEAM", triggeringUserId: "USER" },
snapshot: settled,
state: expandedState,
});
expect(requests.map((r) => r.operation)).toEqual([
"chat.startStream",
"chat.appendStream",
"chat.appendStream",
"chat.appendStream",
"chat.stopStream",
"chat.update",
]);
expect(requests[1]!.body.get("chunks")).toContain("• verify_stage\\n");
expect(requests[2]!.body.get("chunks")).toContain('"id":"reviewer"');
expect(requests[2]!.body.get("chunks")).toContain("• review_stage\\n");
expect(requests[3]!.body.get("chunks")).toContain("✓ verify_stage\\n");
expect(requests[3]!.body.get("chunks")).toContain("✓ review_stage\\n");
expect(requests[5]!.body.get("blocks")).not.toContain("verify_stage");
expect(requests[5]!.body.get("blocks")).toContain("verifier");
});
});
@@ -0,0 +1,247 @@
import type { ChannelActivityRenderer } from "#channel/activity-renderer.js";
import type { ActivitySnapshotV1, ActivityWorkStateV1 } from "#protocol/activity.js";
import { callSlackApi, type SlackBotToken } from "#public/channels/slack/api.js";
export const SLACK_ACTIVITY_PLAN_RENDERER_ID = "slack.experimental.plan.v1";
type Phase = "running" | "completed" | "failed" | "rejected" | "cancelled" | "blocked";
interface PlanState {
readonly streams: Readonly<
Record<
string,
{
readonly ts: string;
readonly seen: Readonly<Record<string, Phase>>;
readonly stopped: boolean;
}
>
>;
}
export function createSlackPlanRenderer(
botToken: SlackBotToken | undefined,
): ChannelActivityRenderer {
return {
id: SLACK_ACTIVITY_PLAN_RENDERER_ID,
async dispose() {},
async render({ destination, snapshot, state }) {
const channel = destination["channelId"],
thread = destination["threadTs"],
team = destination["teamId"],
user = destination["triggeringUserId"],
installation = destination["installationTeamId"];
if (
typeof channel !== "string" ||
typeof thread !== "string" ||
!thread ||
typeof team !== "string" ||
typeof user !== "string"
)
return state;
const previous = isState(state) ? state.streams : {};
const streams: Record<
string,
{ ts: string; seen: Readonly<Record<string, Phase>>; stopped: boolean }
> = { ...previous };
for (const rootTurnId of new Set(
Object.values(snapshot.work).map((work) => work.rootTurnId),
)) {
const view = project(snapshot, rootTurnId);
let current = previous[rootTurnId];
if (!current) {
const response = await api(
"chat.startStream",
{
channel,
thread_ts: thread,
recipient_team_id: team,
recipient_user_id: user,
task_display_mode: "plan",
chunks: [
{ type: "plan_update", title: "Agent activity" },
...view.parents.map(taskChunk),
],
},
botToken,
installation,
);
if (!response.ok || typeof response.ts !== "string")
throw new Error(`Slack activity plan failed: ${response.error ?? "missing ts"}`);
current = {
ts: response.ts,
seen: Object.fromEntries(view.parents.map((parent) => [parent.id, parent.phase])),
stopped: false,
};
}
if (current.stopped) {
streams[rootTurnId] = current;
continue;
}
const updates = detailUpdates(view, current.seen);
if (updates.length)
await checked(
"chat.appendStream",
{ channel, ts: current.ts, chunks: updates },
botToken,
installation,
);
const seen = Object.fromEntries(
[...view.parents, ...view.entities].map((entity) => [entity.id, entity.phase]),
);
if (view.settled) {
await checked("chat.stopStream", { channel, ts: current.ts }, botToken, installation);
const blocks = [
{
type: "plan",
title: "Agent activity",
tasks: view.parents.map((parent) => ({
type: "task_card",
task_id: safeId(parent.id),
title: parent.name,
status: status(parent.phase),
})),
},
];
await checked(
"chat.update",
{ channel, ts: current.ts, text: view.parents.map((p) => p.name).join(", "), blocks },
botToken,
installation,
);
streams[rootTurnId] = { ts: current.ts, seen, stopped: true };
} else streams[rootTurnId] = { ts: current.ts, seen, stopped: false };
}
return { streams } satisfies PlanState;
},
};
}
interface Entity {
id: string;
name: string;
phase: Phase;
parent: string;
}
interface View {
parents: ActivityWorkStateV1[];
entities: Entity[];
settled: boolean;
}
function project(snapshot: ActivitySnapshotV1, rootTurnId: string): View {
const work = Object.values(snapshot.work).filter((w) => w.rootTurnId === rootTurnId);
const roots = work.filter((w) => w.kind === "root-turn");
const rootIds = new Set(roots.map((w) => w.id));
let parents = work.filter((w) => w.parentId && rootIds.has(w.parentId));
if (!parents.length) parents = roots;
const owner = (workId: string): ActivityWorkStateV1 | undefined => {
let item = work.find((w) => w.id === workId);
while (item?.parentId && !parents.some((p) => p.id === item!.id))
item = work.find((w) => w.id === item!.parentId);
return item && parents.find((p) => p.id === item!.id);
};
const entities: Entity[] = [];
for (const child of work)
if (!parents.some((p) => p.id === child.id) && !rootIds.has(child.id)) {
const p = owner(child.id);
if (p)
entities.push({
id: child.id,
name: child.name ?? "Agent work",
phase: child.phase,
parent: p.id,
});
}
for (const action of Object.values(snapshot.actions).filter((a) => a.rootTurnId === rootTurnId)) {
const p = owner(action.parentWorkId);
if (p) entities.push({ id: action.id, name: action.name, phase: action.phase, parent: p.id });
}
for (const blocker of Object.values(snapshot.blockers).filter(
(b) => b.rootTurnId === rootTurnId,
)) {
const p = owner(blocker.parentWorkId);
if (p)
entities.push({
id: blocker.id,
name: blocker.label ?? "Waiting",
phase: blocker.phase,
parent: p.id,
});
}
const settled = [
...work,
...Object.values(snapshot.actions).filter((a) => a.rootTurnId === rootTurnId),
...Object.values(snapshot.blockers).filter((b) => b.rootTurnId === rootTurnId),
].every((e) => e.phase !== "running" && e.phase !== "blocked");
return { parents, entities, settled };
}
function detailUpdates(view: View, seen: Readonly<Record<string, Phase>>) {
const parentUpdates = view.parents
.filter((parent) => seen[parent.id] !== parent.phase)
.map(taskChunk);
const descendantUpdates = view.entities
.filter((entity) => seen[entity.id] !== entity.phase)
.map((entity) => ({
type: "task_update",
id: safeId(entity.parent),
title: view.parents.find((parent) => parent.id === entity.parent)?.name ?? "Agent work",
status: status(
view.parents.find((parent) => parent.id === entity.parent)?.phase ?? "running",
),
details: `${icon(entity.phase)} ${entity.name}\n`,
}));
return [...parentUpdates, ...descendantUpdates];
}
function taskChunk(work: ActivityWorkStateV1) {
return {
type: "task_update",
id: safeId(work.id),
title: work.kind === "root-turn" ? "Agent turn" : (work.name ?? "Agent work"),
status: status(work.phase),
};
}
function status(phase: Phase) {
return phase === "running" || phase === "blocked"
? "in_progress"
: phase === "completed"
? "complete"
: "error";
}
function icon(phase: Phase) {
return phase === "running" || phase === "blocked"
? "•"
: phase === "completed"
? "✓"
: phase === "cancelled"
? ""
: "✗";
}
function safeId(id: string) {
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(-200);
}
async function api(
operation: string,
body: unknown,
botToken: SlackBotToken | undefined,
installation: unknown,
) {
return callSlackApi({
operation,
body,
botToken,
context: { teamId: typeof installation === "string" ? installation : undefined },
});
}
async function checked(
operation: string,
body: unknown,
token: SlackBotToken | undefined,
installation: unknown,
) {
const response = await api(operation, body, token, installation);
if (!response.ok)
throw new Error(`Slack ${operation} failed: ${response.error ?? "unknown_error"}`);
}
function isState(value: unknown): value is PlanState {
return (
typeof value === "object" && value !== null && typeof Reflect.get(value, "streams") === "object"
);
}
@@ -0,0 +1,354 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createActivitySnapshot, reduceActivityBatch } from "#execution/session-activity.js";
import {
activityMessages,
buildSlackActivityRenderers,
experimental_slackActivityTree,
experimental_slackActivityStatus,
} from "#public/channels/slack/activity.js";
const root = {
id: "work:root:turn",
kind: "root-turn" as const,
rootSessionId: "root",
rootTurnId: "turn",
};
const child = {
id: "work:root:turn:child",
kind: "subagent" as const,
name: "research <team>",
parentId: root.id,
rootSessionId: "root",
rootTurnId: "turn",
};
const grandchild = {
id: "work:child:turn:grandchild",
kind: "remote-agent" as const,
name: "tester & reviewer",
parentId: child.id,
rootSessionId: "root",
rootTurnId: "turn",
};
function snapshot() {
return reduceActivityBatch(createActivitySnapshot(), {
events: [
{ eventId: "root", kind: "work.started", startedAt: "2026-01-01T00:00:00Z", work: root },
{ eventId: "child", kind: "work.started", startedAt: "2026-01-01T00:00:01Z", work: child },
{
eventId: "grandchild",
kind: "work.started",
startedAt: "2026-01-01T00:00:02Z",
work: grandchild,
},
{
action: {
id: `${grandchild.id}:search`,
kind: "tool",
name: "search <web>",
parentWorkId: grandchild.id,
rootTurnId: "turn",
stepIndex: 1,
},
eventId: "search-started",
kind: "action.started",
startedAt: "2026-01-01T00:00:03Z",
},
],
version: 1,
});
}
describe("Slack activity activity", () => {
afterEach(() => vi.unstubAllGlobals());
it("derives one nested artifact per root turn and escapes untrusted text", () => {
expect(activityMessages(snapshot())).toEqual(
new Map([
[
"turn",
[
"```",
"• Working",
"└── • research &lt;team&gt;",
" └── • tester &amp; reviewer",
" └── • search &lt;web&gt;",
"```",
].join("\n"),
],
]),
);
});
it("renders a background task instead of its duplicate initiating tool action", () => {
const task = {
callId: "call-background",
id: "work:background",
kind: "subagent" as const,
name: "researcher",
parentId: root.id,
rootSessionId: "root",
rootTurnId: "turn",
};
const background = reduceActivityBatch(createActivitySnapshot(), {
events: [
{ eventId: "root", kind: "work.started", startedAt: "1", work: root },
{
action: {
id: `action:${root.id}:call-background`,
kind: "tool",
name: "researcher",
parentWorkId: root.id,
rootTurnId: "turn",
stepIndex: 0,
},
eventId: "action",
kind: "action.started",
startedAt: "2",
},
{ eventId: "task", kind: "work.started", startedAt: "3", work: task },
],
version: 1,
});
expect(activityMessages(background).get("turn")).toBe("```\n• Working\n└── • researcher\n```");
});
it("keeps temporarily orphaned nested work renderable", () => {
const orphan = reduceActivityBatch(createActivitySnapshot(), {
events: [
{
eventId: "grandchild",
kind: "work.started",
startedAt: "2026-01-01T00:00:02Z",
work: grandchild,
},
],
version: 1,
});
expect(activityMessages(orphan).get("turn")).toContain("tester &amp; reviewer");
});
it("creates a metadata-tagged message and updates it in place", async () => {
const requests: Array<{ body: URLSearchParams; operation: string }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const operation = String(url).split("/").at(-1)!;
requests.push({ body: new URLSearchParams(String(init?.body ?? "")), operation });
if (operation === "conversations.replies") return Response.json({ ok: true, messages: [] });
return Response.json({ ok: true, ts: "1700.1" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityTree()],
})[0]!;
const state = await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: snapshot(),
state: undefined,
});
const settled = reduceActivityBatch(snapshot(), {
events: [
{
eventId: "settled",
kind: "work.settled",
outcome: "completed",
settledAt: "2026-01-01T00:00:05Z",
workId: grandchild.id,
},
],
version: 1,
});
await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: settled,
state,
});
expect(activityMessages(settled).get("turn")).toContain("✓ tester &amp; reviewer");
expect(activityMessages(settled).get("turn")).toContain(" search &lt;web&gt;");
expect(requests.map((request) => request.operation)).toEqual([
"conversations.replies",
"chat.postMessage",
"chat.update",
]);
expect(requests[1]?.body.get("metadata")).toContain('"root_turn_id":"turn"');
expect(requests[2]?.body.get("ts")).toBe("1700.1");
});
it("recreates a deleted activity message", async () => {
const operations: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request) => {
const operation = String(url).split("/").at(-1)!;
operations.push(operation);
return operation === "chat.update"
? Response.json({ error: "message_not_found", ok: false })
: Response.json({ ok: true, ts: "1700.2" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityTree()],
})[0]!;
const state = await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: snapshot(),
state: { messages: { turn: { text: "old", ts: "1700.1" } } },
});
expect(operations).toEqual(["chat.update", "chat.postMessage"]);
expect(state).toMatchObject({ messages: { turn: { ts: "1700.2" } } });
});
it("passes the installation team to activity message token resolution", async () => {
const tokenContext = vi.fn(() => "xoxb-team");
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ ok: true, ts: "1700.1" })),
);
const renderer = buildSlackActivityRenderers({
botToken: tokenContext,
renderers: [experimental_slackActivityTree()],
})[0]!;
await renderer.render({
destination: { channelId: "C1", installationTeamId: "T_INSTALL", threadTs: "T1" },
snapshot: snapshot(),
state: undefined,
});
expect(tokenContext).toHaveBeenCalledWith({ teamId: "T_INSTALL" });
});
it("recovers provider identity from message metadata", async () => {
const operations: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request) => {
const operation = String(url).split("/").at(-1)!;
operations.push(operation);
if (operation === "conversations.replies") {
return Response.json({
messages: [
{
metadata: {
event_payload: { root_turn_id: "turn" },
event_type: "eve_progress",
},
text: "old",
ts: "1700.1",
},
],
ok: true,
});
}
return Response.json({ ok: true, ts: "1700.1" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityTree()],
})[0]!;
await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: snapshot(),
state: undefined,
});
expect(operations).toEqual(["conversations.replies", "chat.update"]);
});
it("paginates metadata recovery until a matching activity message is found", async () => {
const repliesBodies: URLSearchParams[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const operation = String(url).split("/").at(-1)!;
if (operation === "conversations.replies") {
const body = new URLSearchParams(String(init?.body ?? ""));
repliesBodies.push(body);
if (body.get("cursor") === null) {
return Response.json({
messages: [],
ok: true,
response_metadata: { next_cursor: "page-2" },
});
}
return Response.json({
messages: [
{
metadata: {
event_payload: { root_turn_id: "turn" },
event_type: "eve_progress",
},
text: "old",
ts: "1700.1",
},
],
ok: true,
response_metadata: { next_cursor: "" },
});
}
return Response.json({ ok: true, ts: "1700.1" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityTree()],
})[0]!;
await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: snapshot(),
state: undefined,
});
expect(repliesBodies.map((body) => body.get("cursor"))).toEqual([null, "page-2"]);
});
it("stops recovery when Slack repeats a cursor", async () => {
const operations: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request) => {
const operation = String(url).split("/").at(-1)!;
operations.push(operation);
return operation === "conversations.replies"
? Response.json({
messages: [],
ok: true,
response_metadata: { next_cursor: "same" },
})
: Response.json({ ok: true, ts: "1700.2" });
}),
);
const renderer = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityTree()],
})[0]!;
await renderer.render({
destination: { channelId: "C1", threadTs: "T1" },
snapshot: snapshot(),
state: undefined,
});
expect(operations).toEqual([
"conversations.replies",
"conversations.replies",
"chat.postMessage",
]);
});
it("composes activity and status with isolated renderer state", () => {
const renderers = buildSlackActivityRenderers({
botToken: "xoxb-test",
renderers: [experimental_slackActivityStatus(), experimental_slackActivityTree()],
});
expect(renderers.map((renderer) => renderer.id)).toEqual([
"slack.status.v1",
"slack.experimental.tree.v1",
]);
});
});
@@ -5,6 +5,7 @@ import { getChannelActivityPresentation } from "#channel/activity-renderer.js";
import { createActivitySnapshot, reduceActivityBatch } from "#execution/session-activity.js";
import {
buildSlackActivityRenderers,
experimental_slackActivityRenderer,
selectSlackActivityStatus,
experimental_slackActivityStatus,
} from "#public/channels/slack/activity.js";
@@ -46,7 +47,87 @@ describe("Slack status activity", () => {
expect(presentation?.renderers).toHaveLength(1);
expect(
presentation?.destination({ channelId: "C1", secret: "hidden", threadTs: "T1" }),
).toEqual({ channelId: "C1", installationTeamId: null, threadTs: "T1" });
).toEqual({
channelId: "C1",
installationTeamId: null,
teamId: null,
threadTs: "T1",
triggeringUserId: null,
});
});
it("installs an experimental custom renderer", async () => {
const render = vi.fn(async ({ state }: { readonly state: number | undefined }) =>
state === undefined ? 1 : state + 1,
);
const dispose = vi.fn(async () => undefined);
const custom = experimental_slackActivityRenderer<number>({
id: "custom.activity.v1",
render,
dispose,
});
const [renderer] = buildSlackActivityRenderers({ botToken: undefined, renderers: [custom] });
const activitySnapshot = snapshot([
{ eventId: "root", kind: "work.started", startedAt: "2026-01-01T00:00:00Z", work: root },
]);
await expect(
renderer?.render({
destination: {
channelId: "C1",
installationTeamId: "T1",
teamId: null,
threadTs: "M1",
triggeringUserId: null,
},
snapshot: activitySnapshot,
state: undefined,
}),
).resolves.toBe(1);
await renderer?.dispose?.({
destination: {
channelId: "C1",
installationTeamId: "T1",
teamId: null,
threadTs: "M1",
triggeringUserId: null,
},
state: 1,
});
expect(render).toHaveBeenCalledWith({
destination: {
channelId: "C1",
installationTeamId: "T1",
teamId: null,
threadTs: "M1",
triggeringUserId: null,
},
snapshot: activitySnapshot,
state: undefined,
});
expect(dispose).toHaveBeenCalledWith({
destination: {
channelId: "C1",
installationTeamId: "T1",
teamId: null,
threadTs: "M1",
triggeringUserId: null,
},
state: 1,
});
});
it("rejects invalid experimental custom renderers", () => {
expect(() =>
experimental_slackActivityRenderer({ id: "", render: async () => undefined }),
).toThrow("ids must be non-empty");
expect(() =>
experimental_slackActivityRenderer({
id: experimental_slackActivityStatus().id,
render: async () => undefined,
}),
).toThrow("reserved by eve");
});
it("rejects duplicate renderer configuration", () => {
@@ -6,25 +6,97 @@ import type {
ActivityWorkStateV1,
} from "#protocol/activity.js";
import { callSlackApi, type SlackBotToken } from "#public/channels/slack/api.js";
import {
createSlackPlanRenderer,
SLACK_ACTIVITY_PLAN_RENDERER_ID,
} from "#public/channels/slack/activity-plan.js";
import { truncateTypingStatus } from "#public/channels/slack/limits.js";
const SLACK_ACTIVITY_STATUS_RENDERER_ID = "slack.status.v1";
const SLACK_ACTIVITY_MESSAGE_RENDERER_ID = "slack.experimental.tree.v1";
const SLACK_ACTIVITY_RENDERER = Symbol("eve.slack.activity-renderer");
export interface SlackActivityRenderer {
readonly id: typeof SLACK_ACTIVITY_STATUS_RENDERER_ID;
readonly id: string;
readonly [SLACK_ACTIVITY_RENDERER]: true;
}
/**
* Activity snapshot passed to an experimental Slack activity renderer.
*
* This contract is unstable and may change or be removed in any release.
*/
export type ExperimentalSlackActivitySnapshot = ActivitySnapshotV1;
/** Slack destination passed to an experimental activity renderer. */
export interface ExperimentalSlackActivityDestination {
readonly channelId: string | null;
readonly installationTeamId: string | null;
readonly teamId: string | null;
readonly threadTs: string | null;
readonly triggeringUserId: string | null;
}
/**
* Experimental custom Slack activity renderer.
*
* Renderer state is retained by renderer id between snapshots. This contract
* is unstable and may change or be removed in any release.
*/
export interface ExperimentalSlackActivityRenderer<State = unknown> {
readonly id: string;
render(input: {
readonly destination: ExperimentalSlackActivityDestination;
readonly snapshot: ExperimentalSlackActivitySnapshot;
readonly state: State | undefined;
}): Promise<State | undefined>;
dispose?(input: {
readonly destination: ExperimentalSlackActivityDestination;
readonly state: State | undefined;
}): Promise<void>;
}
interface SlackActivityStatusState {
readonly status: string;
}
interface SlackActivityMessageState {
readonly messages: Readonly<Record<string, { readonly text: string; readonly ts: string }>>;
}
/** Creates the compact Slack assistant-thread activity renderer. */
export function experimental_slackActivityStatus(): SlackActivityRenderer {
return { [SLACK_ACTIVITY_RENDERER]: true, id: SLACK_ACTIVITY_STATUS_RENDERER_ID };
}
/** Creates one experimental update-in-place Unicode activity tree per root turn. */
export function experimental_slackActivityTree(): SlackActivityRenderer {
return { [SLACK_ACTIVITY_RENDERER]: true, id: SLACK_ACTIVITY_MESSAGE_RENDERER_ID };
}
/** Creates one experimental native Slack plan stream per root turn. */
export function experimental_slackActivityPlan(): SlackActivityRenderer {
return { [SLACK_ACTIVITY_RENDERER]: true, id: SLACK_ACTIVITY_PLAN_RENDERER_ID };
}
/**
* Registers an experimental custom Slack activity renderer.
*
* The renderer contract is unstable and may change or be removed in any
* release.
*/
export function experimental_slackActivityRenderer<State>(
renderer: ExperimentalSlackActivityRenderer<State>,
): SlackActivityRenderer {
validateCustomSlackActivityRenderer(renderer);
return {
[SLACK_ACTIVITY_RENDERER]: true,
id: renderer.id,
render: renderer.render,
dispose: renderer.dispose,
} as SlackActivityRenderer;
}
export function hasSlackActivityStatus(
renderers: readonly SlackActivityRenderer[] | undefined,
): boolean {
@@ -43,10 +115,94 @@ export function buildSlackActivityRenderers(input: {
if (ids.has(renderer.id))
throw new TypeError(`Duplicate Slack activity renderer "${renderer.id}".`);
ids.add(renderer.id);
return createSlackStatusRenderer(input.botToken);
if (renderer.id === SLACK_ACTIVITY_MESSAGE_RENDERER_ID) {
return createSlackActivityRenderer(input.botToken);
}
if (renderer.id === SLACK_ACTIVITY_PLAN_RENDERER_ID) {
return createSlackPlanRenderer(input.botToken);
}
if (renderer.id === SLACK_ACTIVITY_STATUS_RENDERER_ID) {
return createSlackStatusRenderer(input.botToken);
}
assertCustomSlackActivityRenderer(renderer);
return createCustomSlackActivityRenderer(renderer);
});
}
function assertCustomSlackActivityRenderer(
renderer: SlackActivityRenderer,
): asserts renderer is SlackActivityRenderer & ExperimentalSlackActivityRenderer {
validateCustomSlackActivityRenderer(renderer);
}
function validateCustomSlackActivityRenderer(
renderer: unknown,
): asserts renderer is ExperimentalSlackActivityRenderer {
if (
typeof renderer !== "object" ||
renderer === null ||
!("id" in renderer) ||
typeof renderer.id !== "string" ||
renderer.id.trim() === ""
) {
throw new TypeError("Slack activity renderer ids must be non-empty strings.");
}
if (
renderer.id === SLACK_ACTIVITY_STATUS_RENDERER_ID ||
renderer.id === SLACK_ACTIVITY_MESSAGE_RENDERER_ID ||
renderer.id === SLACK_ACTIVITY_PLAN_RENDERER_ID
) {
throw new TypeError(`Slack activity renderer id "${renderer.id}" is reserved by eve.`);
}
if (!("render" in renderer) || typeof renderer.render !== "function") {
throw new TypeError("Custom Slack activity renderers must define a render function.");
}
if (
"dispose" in renderer &&
renderer.dispose !== undefined &&
typeof renderer.dispose !== "function"
) {
throw new TypeError("Custom Slack activity renderer dispose must be a function.");
}
}
function createCustomSlackActivityRenderer(
renderer: ExperimentalSlackActivityRenderer,
): ChannelActivityRenderer {
return {
id: renderer.id,
async render({ destination, snapshot, state }) {
return renderer.render({
destination: experimentalSlackActivityDestination(destination),
snapshot,
state,
});
},
async dispose({ destination, state }) {
await renderer.dispose?.({
destination: experimentalSlackActivityDestination(destination),
state,
});
},
};
}
function experimentalSlackActivityDestination(
destination: Readonly<Record<string, unknown>>,
): ExperimentalSlackActivityDestination {
return {
channelId: typeof destination["channelId"] === "string" ? destination["channelId"] : null,
installationTeamId:
typeof destination["installationTeamId"] === "string"
? destination["installationTeamId"]
: null,
teamId: typeof destination["teamId"] === "string" ? destination["teamId"] : null,
threadTs: typeof destination["threadTs"] === "string" ? destination["threadTs"] : null,
triggeringUserId:
typeof destination["triggeringUserId"] === "string" ? destination["triggeringUserId"] : null,
};
}
function createSlackStatusRenderer(botToken: SlackBotToken | undefined): ChannelActivityRenderer {
return {
id: SLACK_ACTIVITY_STATUS_RENDERER_ID,
@@ -95,6 +251,272 @@ function createSlackStatusRenderer(botToken: SlackBotToken | undefined): Channel
};
}
function createSlackActivityRenderer(botToken: SlackBotToken | undefined): ChannelActivityRenderer {
return {
id: SLACK_ACTIVITY_MESSAGE_RENDERER_ID,
async dispose() {},
async render({ destination, snapshot, state }) {
const channelId = destination["channelId"];
const installationTeamId = destination["installationTeamId"];
const threadTs = destination["threadTs"];
if (typeof channelId !== "string" || typeof threadTs !== "string" || threadTs === "") {
return state;
}
const previous = isActivityState(state) ? state.messages : {};
const desired = activityMessages(snapshot);
const messages: Record<string, { readonly text: string; readonly ts: string }> =
Object.fromEntries(
Object.entries(previous).filter(([rootTurnId]) => desired.has(rootTurnId)),
);
for (const [rootTurnId, text] of desired) {
const current =
previous[rootTurnId] ??
(await recoverActivityMessage({
botToken,
channelId,
installationTeamId,
rootTurnId,
threadTs,
}));
if (current?.text === text) {
messages[rootTurnId] = current;
continue;
}
let response = await writeActivityMessage({
botToken,
channelId,
current,
installationTeamId,
rootTurnId,
text,
threadTs,
});
if (
response.ok !== true &&
current !== undefined &&
response.error === "message_not_found"
) {
response = await writeActivityMessage({
botToken,
channelId,
installationTeamId,
rootTurnId,
text,
threadTs,
});
}
if (response.ok !== true) {
throw new Error(`Slack activity message failed: ${response.error ?? "unknown_error"}`);
}
const ts = response.ts ?? current?.ts;
if (typeof ts !== "string" || ts === "") {
throw new Error("Slack activity message response did not include ts.");
}
messages[rootTurnId] = { text, ts };
}
return { messages } satisfies SlackActivityMessageState;
},
};
}
async function writeActivityMessage(input: {
readonly botToken: SlackBotToken | undefined;
readonly channelId: string;
readonly current?: { readonly ts: string };
readonly installationTeamId: unknown;
readonly rootTurnId: string;
readonly text: string;
readonly threadTs: string;
}) {
return await callSlackApi({
body:
input.current === undefined
? {
channel: input.channelId,
metadata: {
event_payload: { root_turn_id: input.rootTurnId },
event_type: "eve_progress",
},
text: input.text,
thread_ts: input.threadTs,
}
: { channel: input.channelId, text: input.text, ts: input.current.ts },
botToken: input.botToken,
context: {
teamId: typeof input.installationTeamId === "string" ? input.installationTeamId : undefined,
},
operation: input.current === undefined ? "chat.postMessage" : "chat.update",
});
}
async function recoverActivityMessage(input: {
readonly botToken: SlackBotToken | undefined;
readonly channelId: string;
readonly installationTeamId: unknown;
readonly rootTurnId: string;
readonly threadTs: string;
}): Promise<{ readonly text: string; readonly ts: string } | undefined> {
let cursor: string | undefined;
const seenCursors = new Set<string>();
while (true) {
const body: Record<string, unknown> = {
channel: input.channelId,
inclusive: true,
limit: 100,
ts: input.threadTs,
};
if (cursor !== undefined) body.cursor = cursor;
const response = await callSlackApi({
body,
botToken: input.botToken,
context: {
teamId: typeof input.installationTeamId === "string" ? input.installationTeamId : undefined,
},
operation: "conversations.replies",
});
if (response.ok !== true || !Array.isArray(response.messages)) return undefined;
for (const message of response.messages) {
if (message === null || typeof message !== "object") continue;
const metadata = Reflect.get(message, "metadata");
const payload =
metadata !== null && typeof metadata === "object"
? Reflect.get(metadata, "event_payload")
: undefined;
if (
metadata !== null &&
typeof metadata === "object" &&
Reflect.get(metadata, "event_type") === "eve_progress" &&
payload !== null &&
typeof payload === "object" &&
Reflect.get(payload, "root_turn_id") === input.rootTurnId
) {
const ts = Reflect.get(message, "ts");
const text = Reflect.get(message, "text");
if (typeof ts === "string") return { text: typeof text === "string" ? text : "", ts };
}
}
const responseMetadata = Reflect.get(response, "response_metadata");
const nextCursor =
responseMetadata !== null && typeof responseMetadata === "object"
? Reflect.get(responseMetadata, "next_cursor")
: undefined;
if (typeof nextCursor !== "string" || nextCursor === "" || seenCursors.has(nextCursor)) {
return undefined;
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
}
export function activityMessages(snapshot: ActivitySnapshotV1): ReadonlyMap<string, string> {
const grouped = new Map<string, ActivityWorkStateV1[]>();
for (const work of Object.values(snapshot.work)) {
const group = grouped.get(work.rootTurnId) ?? [];
group.push(work);
grouped.set(work.rootTurnId, group);
}
return new Map(
[...grouped].map(([rootTurnId, work]) => [
rootTurnId,
renderWorkTree(
work,
Object.values(snapshot.actions).filter((action) => action.rootTurnId === rootTurnId),
Object.values(snapshot.blockers).filter((blocker) => blocker.rootTurnId === rootTurnId),
),
]),
);
}
function renderWorkTree(
work: readonly ActivityWorkStateV1[],
actions: readonly ActivityActionStateV1[],
blockers: readonly ActivityBlockerStateV1[],
): string {
const byParent = new Map<string | undefined, ActivityWorkStateV1[]>();
const ids = new Set(work.map((item) => item.id));
for (const item of work) {
const parentId =
item.parentId !== undefined && ids.has(item.parentId) ? item.parentId : undefined;
const children = byParent.get(parentId) ?? [];
children.push(item);
byParent.set(parentId, children);
}
const backgroundActionIds = new Set(
work.flatMap((item) =>
item.parentId !== undefined && item.callId !== undefined
? [`action:${item.parentId}:${item.callId}`]
: [],
),
);
const actionsByParent = new Map<string, ActivityActionStateV1[]>();
for (const action of actions) {
if (action.kind === "tool" && backgroundActionIds.has(action.id)) continue;
const siblings = actionsByParent.get(action.parentWorkId) ?? [];
siblings.push(action);
actionsByParent.set(action.parentWorkId, siblings);
}
const blockersByParent = new Map<string, ActivityBlockerStateV1[]>();
for (const blocker of blockers) {
const siblings = blockersByParent.get(blocker.parentWorkId) ?? [];
siblings.push(blocker);
blockersByParent.set(blocker.parentWorkId, siblings);
}
const lines: string[] = [];
const append = (line: string): void => {
if (lines.length < 20) lines.push(line);
};
const visit = (item: ActivityWorkStateV1, prefix: string, connector: string): void => {
const label = item.kind === "root-turn" ? "Working" : (item.name ?? "Agent work");
append(`${prefix}${connector}${phaseIcon(item.phase)} ${escapeSlackText(label)}`);
const descendants = [
...(blockersByParent.get(item.id) ?? []).map((blocker) => ({ blocker })),
...(actionsByParent.get(item.id) ?? []).map((action) => ({ action })),
...(byParent.get(item.id) ?? []).map((child) => ({ child })),
];
const childPrefix = `${prefix}${connector === "├── " ? "│ " : connector === "└── " ? " " : ""}`;
descendants.forEach((descendant, index) => {
const branch = index === descendants.length - 1 ? "└── " : "├── ";
if ("blocker" in descendant)
append(
`${childPrefix}${branch}${blockerIcon(descendant.blocker.phase)} ${escapeSlackText(descendant.blocker.label ?? blockerLabel(descendant.blocker.kind))}`,
);
else if ("action" in descendant)
append(
`${childPrefix}${branch}${phaseIcon(descendant.action.phase)} ${escapeSlackText(descendant.action.name)}`,
);
else visit(descendant.child, childPrefix, branch);
});
};
for (const root of byParent.get(undefined) ?? []) visit(root, "", "");
return `\`\`\`\n${lines.join("\n")}\n\`\`\``;
}
function phaseIcon(phase: ActivityWorkStateV1["phase"] | ActivityActionStateV1["phase"]): string {
switch (phase) {
case "completed":
return "✓";
case "failed":
case "rejected":
return "✗";
case "cancelled":
return "";
case "running":
return "•";
}
}
function escapeSlackText(text: string): string {
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function isActivityState(value: unknown): value is SlackActivityMessageState {
return (
typeof value === "object" &&
value !== null &&
typeof Reflect.get(value, "messages") === "object"
);
}
export function selectSlackActivityStatus(snapshot: ActivitySnapshotV1): string {
const active = Object.values(snapshot.work).filter((work) => work.phase === "running");
if (active.length === 0) return "";
@@ -122,26 +544,32 @@ function blockerLabel(kind: ActivityBlockerStateV1["kind"]): string {
}
}
function blockerIcon(phase: ActivityBlockerStateV1["phase"]): string {
return phase === "blocked" ? "◌" : phase === "completed" ? "✓" : phase === "failed" ? "✗" : "";
}
function newestBlocker(
blockers: readonly ActivityBlockerStateV1[],
): ActivityBlockerStateV1 | undefined {
return newestByStartedAt(blockers);
return blockers.reduce<ActivityBlockerStateV1 | undefined>(
(newest, candidate) =>
newest === undefined || candidate.startedAt >= newest.startedAt ? candidate : newest,
undefined,
);
}
function newestAction(
actions: readonly ActivityActionStateV1[],
): ActivityActionStateV1 | undefined {
return newestByStartedAt(actions);
return actions.reduce<ActivityActionStateV1 | undefined>(
(newest, candidate) =>
newest === undefined || candidate.startedAt >= newest.startedAt ? candidate : newest,
undefined,
);
}
function newestWork(work: readonly ActivityWorkStateV1[]): ActivityWorkStateV1 | undefined {
return newestByStartedAt(work);
}
function newestByStartedAt<T extends { readonly startedAt: string }>(
values: readonly T[],
): T | undefined {
return values.reduce<T | undefined>(
return work.reduce<ActivityWorkStateV1 | undefined>(
(newest, candidate) =>
newest === undefined || candidate.startedAt >= newest.startedAt ? candidate : newest,
undefined,
@@ -76,7 +76,13 @@ export {
export { defaultSlackAuth } from "#public/channels/slack/defaults.js";
export {
experimental_slackActivityPlan,
experimental_slackActivityRenderer,
experimental_slackActivityTree,
experimental_slackActivityStatus,
type ExperimentalSlackActivityDestination,
type ExperimentalSlackActivityRenderer,
type ExperimentalSlackActivitySnapshot,
type SlackActivityRenderer,
} from "#public/channels/slack/activity.js";
@@ -985,7 +985,9 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel {
return {
channelId: slack?.channelId ?? null,
installationTeamId: slack?.installationTeamId ?? null,
teamId: slack?.teamId ?? null,
threadTs: slack?.threadTs ?? null,
triggeringUserId: slack?.triggeringUserId ?? null,
};
},
renderers: activityRenderers,