mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
[1/5] feat(slack): render activity messages (#2310)
Signed-off-by: benpankow <ben.pankow@vercel.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 <team>",
|
||||
" └── • tester & reviewer",
|
||||
" └── • search <web>",
|
||||
"```",
|
||||
].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 & 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 & reviewer");
|
||||
expect(activityMessages(settled).get("turn")).toContain("– search <web>");
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user