From 420de1ba95739ab2d791f4aa0cc65994ca97f047 Mon Sep 17 00:00:00 2001 From: benpankow Date: Wed, 29 Jul 2026 14:19:52 -0700 Subject: [PATCH] feat(eve): expose authorized approval lifecycle Signed-off-by: benpankow --- packages/eve/src/channel/routes.ts | 1 + packages/eve/src/channel/send.test.ts | 37 +++- packages/eve/src/channel/send.ts | 3 +- .../eve/src/client/message-reducer.test.ts | 14 +- packages/eve/src/client/message-reducer.ts | 36 ++++ packages/eve/src/execution/workflow-steps.ts | 21 ++- .../eve/src/harness/approval-candidates.ts | 56 ++++++ .../eve/src/harness/authorization.test.ts | 50 +++++ packages/eve/src/harness/authorization.ts | 10 +- packages/eve/src/harness/tool-loop.ts | 79 ++++++++ packages/eve/src/protocol/message.ts | 56 ++++++ .../public/channels/slack/defaults.test.ts | 149 +++++++++++++++ .../eve/src/public/channels/slack/defaults.ts | 81 +++++++- .../src/public/channels/slack/interactions.ts | 177 ++---------------- .../channels/slack/slackChannel.test.ts | 79 ++------ .../src/public/channels/slack/slackChannel.ts | 33 +++- .../src/public/definitions/channel.test.ts | 40 ++++ .../eve/src/public/definitions/channel.ts | 9 +- packages/eve/src/public/definitions/hook.ts | 9 +- packages/eve/src/shared/channel-definition.ts | 4 +- 20 files changed, 687 insertions(+), 257 deletions(-) create mode 100644 packages/eve/src/harness/authorization.test.ts diff --git a/packages/eve/src/channel/routes.ts b/packages/eve/src/channel/routes.ts index d2f5f2bfc..cd67677ae 100644 --- a/packages/eve/src/channel/routes.ts +++ b/packages/eve/src/channel/routes.ts @@ -43,6 +43,7 @@ export interface RouteHandlerArgs { export interface SendPayload { readonly message?: string | UserContent; + readonly [key: string]: unknown; readonly inputResponses?: readonly InputResponse[]; /** * Context strings contributed by the channel. eve appends each entry diff --git a/packages/eve/src/channel/send.test.ts b/packages/eve/src/channel/send.test.ts index 17c078cb0..a17139fa9 100644 --- a/packages/eve/src/channel/send.test.ts +++ b/packages/eve/src/channel/send.test.ts @@ -4,12 +4,12 @@ import type { ChannelAdapter } from "#channel/adapter.js"; import { createSendFn } from "#channel/send.js"; import type { RunHandle, Runtime } from "#channel/types.js"; import { RuntimeNoActiveSessionError } from "#execution/runtime-errors.js"; -import type { MessageStreamEvent } from "#protocol/message.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; function createMockRunHandle(): RunHandle { return { continuationToken: "test:token", - events: new ReadableStream(), + events: new ReadableStream(), sessionId: "mock-session-id", }; } @@ -20,7 +20,7 @@ function createRuntime(deliverError: unknown): Runtime { deliver: vi.fn().mockRejectedValue(deliverError), resolveSession: vi.fn(), run: vi.fn().mockResolvedValue(createMockRunHandle()), - getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), + getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), getStreamTailIndex: vi.fn().mockResolvedValue(-1), terminateSession: vi.fn(), }; @@ -77,6 +77,31 @@ describe("createSendFn", () => { expect(runtime.run).not.toHaveBeenCalled(); }); + it("preserves channel-specific fields through delivery", async () => { + const runtime: Runtime = { + cancelTurn: vi.fn(), + deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }), + resolveSession: vi.fn(), + run: vi.fn(), + getEventStream: vi.fn(), + getStreamTailIndex: vi.fn(), + terminateSession: vi.fn(), + }; + const send = createSendFn(runtime, ADAPTER, "test"); + + await send( + { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + pendingApprovalCards: { "approval-1": { messageTs: "123.456" } }, + }, + { auth: null, continuationToken: "token" }, + ); + + expect(vi.mocked(runtime.deliver).mock.calls[0]?.[0].payload).toMatchObject({ + pendingApprovalCards: { "approval-1": { messageTs: "123.456" } }, + }); + }); + it("forwards context through deliver and run payloads", async () => { const context = ["thread background"]; const deliverRuntime: Runtime = { @@ -84,7 +109,7 @@ describe("createSendFn", () => { deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }), resolveSession: vi.fn(), run: vi.fn().mockResolvedValue(createMockRunHandle()), - getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), + getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), getStreamTailIndex: vi.fn().mockResolvedValue(-1), terminateSession: vi.fn(), }; @@ -119,7 +144,7 @@ describe("createSendFn", () => { deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }), resolveSession: vi.fn(), run: vi.fn().mockResolvedValue(createMockRunHandle()), - getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), + getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), getStreamTailIndex: vi.fn().mockResolvedValue(-1), terminateSession: vi.fn(), }; @@ -149,7 +174,7 @@ describe("createSendFn", () => { deliver: vi.fn().mockResolvedValue({ sessionId: "existing-session-id" }), resolveSession: vi.fn(), run: vi.fn().mockResolvedValue(createMockRunHandle()), - getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), + getEventStream: vi.fn().mockResolvedValue(new ReadableStream()), getStreamTailIndex: vi.fn().mockResolvedValue(-1), terminateSession: vi.fn(), }; diff --git a/packages/eve/src/channel/send.ts b/packages/eve/src/channel/send.ts index 2ed42b423..57653a923 100644 --- a/packages/eve/src/channel/send.ts +++ b/packages/eve/src/channel/send.ts @@ -31,6 +31,7 @@ export function createSendFn( inputResponses, context, outputSchema, + ...channelPayload } = normalizeSendInput(input); const message = serializeUrlFilePartsInMessage(rawMessage); @@ -39,7 +40,7 @@ export function createSendFn( auth, continuationToken, requestId: metadata.requestId, - payload: { inputResponses, message, context, outputSchema }, + payload: { ...channelPayload, inputResponses, message, context, outputSchema }, }; const { sessionId } = await runtime.deliver(deliverInput); diff --git a/packages/eve/src/client/message-reducer.test.ts b/packages/eve/src/client/message-reducer.test.ts index f8131f9b0..fe0886a1c 100644 --- a/packages/eve/src/client/message-reducer.test.ts +++ b/packages/eve/src/client/message-reducer.test.ts @@ -371,7 +371,7 @@ describe("defaultMessageReducer", () => { kind: "tool-approval", options: [ { id: "approve", label: "Yes", style: "primary" }, - { id: "cancel", label: "No", style: "danger" }, + { id: "deny", label: "No", style: "danger" }, ], prompt: "Approve tool call: bash", requestId: "approval_1", @@ -408,7 +408,7 @@ describe("defaultMessageReducer", () => { kind: "tool-approval", options: [ { id: "approve", label: "Yes", style: "primary" }, - { id: "cancel", label: "No", style: "danger" }, + { id: "deny", label: "No", style: "danger" }, ], prompt: "Approve tool call: bash", requestId: "approval_1", @@ -442,7 +442,7 @@ describe("defaultMessageReducer", () => { kind: "tool-approval", options: [ { id: "approve", label: "Yes", style: "primary" }, - { id: "cancel", label: "No", style: "danger" }, + { id: "deny", label: "No", style: "danger" }, ], prompt: "Approve tool call: bash", requestId: "approval_1", @@ -457,7 +457,7 @@ describe("defaultMessageReducer", () => { data = reducer.reduce(data, { data: { createdAt: 1, - responses: [{ optionId: "cancel", requestId: "approval_1" }], + responses: [{ optionId: "deny", requestId: "approval_1" }], }, type: "client.input.responded", }); @@ -487,12 +487,12 @@ describe("defaultMessageReducer", () => { kind: "tool-approval", options: [ { id: "approve", label: "Yes", style: "primary" }, - { id: "cancel", label: "No", style: "danger" }, + { id: "deny", label: "No", style: "danger" }, ], prompt: "Approve tool call: bash", requestId: "approval_1", }, - inputResponse: { optionId: "cancel", requestId: "approval_1" }, + inputResponse: { optionId: "deny", requestId: "approval_1" }, kind: "tool-call", name: "bash", }, @@ -522,7 +522,7 @@ describe("defaultMessageReducer", () => { kind: "tool-approval", options: [ { id: "approve", label: "Yes", style: "primary" }, - { id: "cancel", label: "No", style: "danger" }, + { id: "deny", label: "No", style: "danger" }, ], prompt: "Approve tool call: bash", requestId: "approval_1", diff --git a/packages/eve/src/client/message-reducer.ts b/packages/eve/src/client/message-reducer.ts index a033a6085..b80d0628b 100644 --- a/packages/eve/src/client/message-reducer.ts +++ b/packages/eve/src/client/message-reducer.ts @@ -168,6 +168,42 @@ function reduceMessageData(data: EveMessageData, event: EveAgentReducerEvent): E return next; } + case "approval.candidate": + // Candidate progress is responder-specific. Applications can consume the + // raw stream event for private UI without changing the shared tool part. + return data; + + case "approval.settled": { + const existing = findToolPartByApprovalId(data, event.data.requestId); + if (existing === undefined) return data; + if (event.data.outcome === "approved") { + return updateToolPart(data, existing.toolCallId, { + approval: { approved: true, id: event.data.requestId, reason: undefined }, + input: existing.input, + state: "approval-responded", + stepIndex: existing.stepIndex, + toolCallId: existing.toolCallId, + toolMetadata: existing.toolMetadata, + toolName: existing.toolName, + type: "dynamic-tool", + }); + } + return updateToolPart(data, existing.toolCallId, { + approval: { + approved: false, + id: event.data.requestId, + reason: "Tool execution was cancelled.", + }, + input: existing.input, + state: "output-denied", + stepIndex: existing.stepIndex, + toolCallId: existing.toolCallId, + toolMetadata: existing.toolMetadata, + toolName: existing.toolName, + type: "dynamic-tool", + }); + } + case "action.result": { const descriptor = normalizeActionResult(event.data.result); const existing = findToolPart(data, event.data.result.callId); diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index b7a37bf14..92e36bce9 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -166,11 +166,19 @@ export async function turnStep(rawInput: TurnStepInput): Promise + | Array<{ + candidateId?: string; + name: string; + authorization: ConnectionAuthorizationChallenge; + }> | undefined; if (pendingAuth && input.input?.kind === "deliver") { const authResults: Array<{ name: string } & AuthorizationResult> = []; - const completed: Array<{ name: string; authorization: ConnectionAuthorizationChallenge }> = []; + const completed: Array<{ + candidateId?: string; + name: string; + authorization: ConnectionAuthorizationChallenge; + }> = []; const remainingPayloads: DeliverPayload[] = []; for (const payload of input.input.payloads) { const cb = payload["authorizationCallback"] as @@ -185,7 +193,11 @@ export async function turnStep(rawInput: TurnStepInput): Promise { + if (candidate.candidateId !== input.candidateId || candidate.eventEmitted === true) { + return candidate; + } + changed = true; + return { ...candidate, eventEmitted: true }; + }); + return changed + ? writeApprovalState(input.state, { ...approvalState, candidateHistory }) + : input.state; +} + +/** Marks a terminal settlement event as emitted. */ +export function markApprovalSettlementEventEmitted(input: { + readonly requestId: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { + const approvalState = readApprovalState(input.state); + const settlement = approvalState.settlements[input.requestId]; + if (settlement === undefined || settlement.eventEmitted === true) return input.state; + return writeApprovalState(input.state, { + ...approvalState, + settlements: { + ...approvalState.settlements, + [input.requestId]: { ...settlement, eventEmitted: true }, + }, + }); +} + /** Marks a candidate as waiting on a private authorization challenge. */ export function markApprovalCandidateAuthorizationRequired(input: { readonly authorizationChallenges: readonly AuthorizationChallenge[]; diff --git a/packages/eve/src/harness/authorization.test.ts b/packages/eve/src/harness/authorization.test.ts new file mode 100644 index 000000000..d6e456f81 --- /dev/null +++ b/packages/eve/src/harness/authorization.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + getPendingAuthorization, + setPendingAuthorization, + type AuthorizationChallenge, +} from "#harness/authorization.js"; + +function challenge(name: string, candidateId: string): AuthorizationChallenge { + return { + candidateId, + challenge: { url: `https://example.com/${name}` }, + hookUrl: `https://eve.example/${name}`, + name, + }; +} + +describe("pending authorization state", () => { + it("merges concurrent candidate challenges by authorization name", () => { + const first = setPendingAuthorization(undefined, { + challenges: [challenge("candidate-1:github", "candidate-1")], + }); + const second = setPendingAuthorization(first, { + challenges: [challenge("candidate-2:github", "candidate-2")], + }); + + expect(getPendingAuthorization(second)?.challenges).toEqual([ + expect.objectContaining({ candidateId: "candidate-1", name: "candidate-1:github" }), + expect.objectContaining({ candidateId: "candidate-2", name: "candidate-2:github" }), + ]); + }); + + it("replaces a repeated challenge without duplicating it", () => { + const first = setPendingAuthorization(undefined, { + challenges: [challenge("candidate-1:github", "candidate-1")], + }); + const second = setPendingAuthorization(first, { + challenges: [ + { + ...challenge("candidate-1:github", "candidate-1"), + hookUrl: "https://eve.example/refreshed", + }, + ], + }); + + expect(getPendingAuthorization(second)?.challenges).toEqual([ + expect.objectContaining({ hookUrl: "https://eve.example/refreshed" }), + ]); + }); +}); diff --git a/packages/eve/src/harness/authorization.ts b/packages/eve/src/harness/authorization.ts index 76bdd41f9..b7d9316c6 100644 --- a/packages/eve/src/harness/authorization.ts +++ b/packages/eve/src/harness/authorization.ts @@ -46,6 +46,7 @@ const AUTHORIZATION_PENDING_BRAND = "__eveAuthorizationPending" as const; // --------------------------------------------------------------------------- export interface AuthorizationChallenge { + readonly candidateId?: string; readonly name: string; readonly challenge: ConnectionAuthorizationChallenge; readonly hookUrl: string; @@ -104,6 +105,7 @@ export function requestAuthorization( export function redactSignalResume(signal: AuthorizationSignal): AuthorizationSignal { return requestAuthorization( signal.challenges.map((entry) => ({ + candidateId: entry.candidateId, name: entry.name, challenge: entry.challenge, hookUrl: entry.hookUrl, @@ -240,7 +242,13 @@ export function setPendingAuthorization( sessionState: Record | undefined, value: PendingAuthorizationState, ): Record { - return { ...sessionState, [PENDING_AUTHORIZATION_KEY]: value }; + const existing = getPendingAuthorization(sessionState)?.challenges ?? []; + const byName = new Map(existing.map((challenge) => [challenge.name, challenge])); + for (const challenge of value.challenges) byName.set(challenge.name, challenge); + return { + ...sessionState, + [PENDING_AUTHORIZATION_KEY]: { challenges: [...byName.values()] }, + }; } export function clearPendingAuthorization( diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index e9194a75a..28eac3edf 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -41,6 +41,8 @@ import { PendingSkillAnnouncementKey } from "#context/dynamic-skill-lifecycle.js import { toErrorMessage } from "#shared/errors.js"; import { createActionResultEvent, + createApprovalCandidateEvent, + createApprovalSettledEvent, createCompactionCompletedEvent, createCompactionRequestedEvent, createInputRequestedEvent, @@ -106,6 +108,12 @@ import { } from "#harness/input-extraction.js"; import { createToolResultMessagePartFromToolError } from "#harness/action-result-helpers.js"; import { activeTurnId } from "#harness/active-turn-id.js"; +import { + getApprovalAuditState, + markApprovalCandidateHistoryEventEmitted, + markApprovalCandidatePendingEventEmitted, + markApprovalSettlementEventEmitted, +} from "#harness/approval-candidates.js"; import { coordinateApprovalDelivery } from "#harness/approval-delivery-coordinator.js"; import { buildTelemetryRuntimeContext } from "#harness/instrumentation-runtime-context.js"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; @@ -595,6 +603,76 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { tools: responseAuthorizationTools, }); session = coordinated.session; + if (emit) { + const audit = getApprovalAuditState(session.state); + for (const candidate of audit.activeCandidates.filter( + (entry) => entry.pendingEventEmitted !== true, + )) { + await emit( + createApprovalCandidateEvent({ + candidateId: candidate.candidateId, + outcome: "pending", + requestId: candidate.requestId, + responderPrincipalId: candidate.responder.principalId, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + session = { + ...session, + state: markApprovalCandidatePendingEventEmitted({ + candidateId: candidate.candidateId, + state: session.state, + }), + }; + } + for (const candidate of audit.candidateHistory.filter( + (entry) => entry.eventEmitted !== true && entry.status !== "allowed", + )) { + await emit( + createApprovalCandidateEvent({ + candidateId: candidate.candidateId, + outcome: candidate.status as Exclude< + typeof candidate.status, + "allowed" | "authorization-required" + >, + requestId: candidate.requestId, + responderPrincipalId: candidate.responder.principalId, + safeReason: candidate.safeReason, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + session = { + ...session, + state: markApprovalCandidateHistoryEventEmitted({ + candidateId: candidate.candidateId, + state: session.state, + }), + }; + } + for (const settlement of audit.settlements.filter((entry) => entry.eventEmitted !== true)) { + await emit( + createApprovalSettledEvent({ + outcome: settlement.outcome === "allowed" ? "approved" : "cancelled", + requestId: settlement.requestId, + responderPrincipalId: settlement.actor.principalId, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + session = { + ...session, + state: markApprovalSettlementEventEmitted({ + requestId: settlement.requestId, + state: session.state, + }), + }; + } + } if (coordinated.kind === "continue-coordination") { const continuedSession = coordinated.stepInput === undefined @@ -608,6 +686,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { await emit( createAuthorizationRequiredEvent({ authorization: challenge.challenge, + candidateId: challenge.candidateId, description: challenge.challenge.instructions ?? `Authorization required for ${challenge.name}`, name: challenge.name, diff --git a/packages/eve/src/protocol/message.ts b/packages/eve/src/protocol/message.ts index 97b361c0e..f04b4d149 100644 --- a/packages/eve/src/protocol/message.ts +++ b/packages/eve/src/protocol/message.ts @@ -224,6 +224,36 @@ export interface ActionsRequestedStreamEvent { type: "actions.requested"; } +export type ApprovalCandidateOutcome = "pending" | "rejected" | "failed" | "timed-out" | "stale"; + +/** Safe lifecycle event for one responder-bound approval candidate. */ +export interface ApprovalCandidateStreamEvent { + data: { + candidateId: string; + outcome: ApprovalCandidateOutcome; + requestId: string; + responderPrincipalId: string; + safeReason?: string; + sequence: number; + stepIndex: number; + turnId: string; + }; + type: "approval.candidate"; +} + +/** Terminal durable settlement for one approval request. */ +export interface ApprovalSettledStreamEvent { + data: { + outcome: "approved" | "cancelled"; + requestId: string; + responderPrincipalId: string; + sequence: number; + stepIndex: number; + turnId: string; + }; + type: "approval.settled"; +} + /** * Stream event emitted when the harness needs human input before it can * continue the run. @@ -511,6 +541,7 @@ export interface CompactionCompletedStreamEvent { export interface AuthorizationRequiredStreamEvent { data: { authorization?: ConnectionAuthorizationChallenge; + candidateId?: string; description: string; name: string; sequence: number; @@ -542,6 +573,7 @@ export type ConnectionAuthorizationOutcome = AuthorizationOutcome; */ export interface AuthorizationCompletedStreamEvent { data: { + candidateId?: string; /** * The challenge from the matching `authorization.required` event, * journaled across the park. Lets channels keep rendering the @@ -598,6 +630,8 @@ export interface SessionCompletedStreamEvent { * consumers receive {@link MessageStreamEvent}. */ export type UnstampedMessageStreamEvent = + | ApprovalCandidateStreamEvent + | ApprovalSettledStreamEvent | CompactionCompletedStreamEvent | CompactionRequestedStreamEvent | AuthorizationCompletedStreamEvent @@ -957,6 +991,7 @@ export function createActionsRequestedEvent(input: { */ export function createAuthorizationRequiredEvent(input: { readonly authorization?: ConnectionAuthorizationChallenge; + readonly candidateId?: string; readonly description: string; readonly name: string; readonly sequence: number; @@ -974,6 +1009,9 @@ export function createAuthorizationRequiredEvent(input: { if (input.authorization !== undefined) { data.authorization = input.authorization; } + if (input.candidateId !== undefined) { + data.candidateId = input.candidateId; + } if (input.webhookUrl !== undefined) { data.webhookUrl = input.webhookUrl; } @@ -990,6 +1028,7 @@ export function createAuthorizationRequiredEvent(input: { */ export function createAuthorizationCompletedEvent(input: { readonly authorization?: ConnectionAuthorizationChallenge; + readonly candidateId?: string; readonly name: string; readonly outcome: AuthorizationOutcome; readonly reason?: string; @@ -1007,6 +1046,9 @@ export function createAuthorizationCompletedEvent(input: { if (input.authorization !== undefined) { data.authorization = input.authorization; } + if (input.candidateId !== undefined) { + data.candidateId = input.candidateId; + } if (input.reason !== undefined) { data.reason = input.reason; } @@ -1016,6 +1058,20 @@ export function createAuthorizationCompletedEvent(input: { }; } +/** Creates a safe candidate lifecycle event. */ +export function createApprovalCandidateEvent( + input: ApprovalCandidateStreamEvent["data"], +): ApprovalCandidateStreamEvent { + return { data: input, type: "approval.candidate" }; +} + +/** Creates a terminal approval settlement event. */ +export function createApprovalSettledEvent( + input: ApprovalSettledStreamEvent["data"], +): ApprovalSettledStreamEvent { + return { data: input, type: "approval.settled" }; +} + /** * Creates the `input.requested` event for one pending HITL batch. */ diff --git a/packages/eve/src/public/channels/slack/defaults.test.ts b/packages/eve/src/public/channels/slack/defaults.test.ts index 804814b09..7c8707c0e 100644 --- a/packages/eve/src/public/channels/slack/defaults.test.ts +++ b/packages/eve/src/public/channels/slack/defaults.test.ts @@ -51,6 +51,142 @@ function authRequiredEvent( }; } +describe("defaultEvents approval lifecycle", () => { + it("sends candidate progress privately", async () => { + const { channel, postEphemeral } = buildChannelStub(); + const ctx = sessionContext({ + attributes: { user_id: "U777" }, + authenticator: "slack-webhook", + principalId: "slack:T1:U777", + principalType: "user", + }); + + await defaultEvents["approval.candidate"]!( + { + candidateId: "candidate-1", + outcome: "pending", + requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + ctx, + ); + + expect(postEphemeral).toHaveBeenCalledWith( + "U777", + "Checking whether you can approve this action…", + ); + }); + + it("delivers an immediate rejection to the current responder without prior mapping", async () => { + const { channel, postEphemeral } = buildChannelStub(); + const ctx = sessionContext({ + attributes: { user_id: "U777" }, + authenticator: "slack-webhook", + principalId: "slack:T1:U777", + principalType: "user", + }); + + await defaultEvents["approval.candidate"]!( + { + candidateId: "candidate-1", + outcome: "rejected", + requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", + safeReason: "GitHub write access is required.", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + ctx, + ); + + expect(postEphemeral).toHaveBeenCalledWith("U777", "GitHub write access is required."); + }); + + it("updates the shared card only after settlement", async () => { + const { channel, request } = buildChannelStub({ + pendingApprovalCards: { + "approval-1": { + actionId: "eve_input:approval-1:button:1", + messageBlocks: [ + { + actions: [{ action_id: "eve_input:approval-1:button:1" }], + body: { text: "Approve?", type: "mrkdwn" }, + type: "card", + }, + ], + messageTs: "123.456", + userId: "U777", + }, + }, + }); + + await defaultEvents["approval.settled"]!( + { + outcome: "approved", + requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + sessionCtx, + ); + + expect(request).toHaveBeenCalledWith( + "chat.update", + expect.objectContaining({ channel: "C123", text: "Answered: Approve", ts: "123.456" }), + ); + expect(channel.state.pendingApprovalCards).toEqual({}); + }); + + it("settles the request even when the stored click id differs from its sibling button", async () => { + const { channel, request } = buildChannelStub({ + pendingApprovalCards: { + "approval-1": { + actionId: "eve_input:approval-1:button:1", + messageBlocks: [ + { + actions: [ + { action_id: "eve_input:approval-1:button:0" }, + { action_id: "eve_input:approval-1:button:1" }, + ], + body: { text: "Approve?", type: "mrkdwn" }, + type: "card", + }, + ], + messageTs: "123.456", + userId: "U777", + }, + }, + }); + + await defaultEvents["approval.settled"]!( + { + outcome: "approved", + requestId: "approval-1", + responderPrincipalId: "slack:T1:U777", + sequence: 1, + stepIndex: 0, + turnId: "turn-1", + }, + channel, + sessionCtx, + ); + + const update = request.mock.calls.find(([method]) => method === "chat.update")?.[1] as { + blocks?: unknown[]; + }; + expect(JSON.stringify(update.blocks)).not.toContain("eve_input:approval-1"); + }); +}); + describe("defaultEvents authorization.required", () => { it("posts a public status and delivers the challenge ephemerally to the triggering user", async () => { const { channel, post, postEphemeral } = buildChannelStub({ triggeringUserId: "U777" }); @@ -68,6 +204,19 @@ describe("defaultEvents authorization.required", () => { expect(channel.state.pendingAuthMessageTs).toEqual({ notion: "ts1" }); }); + it("does not post a public status for candidate-scoped authorization", async () => { + const { channel, post, postEphemeral } = buildChannelStub({ triggeringUserId: "U777" }); + + await defaultEvents["authorization.required"]!( + { ...authRequiredEvent(), candidateId: "candidate-1" }, + channel, + sessionCtx, + ); + + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + it("targets the current Slack caller instead of stale channel state", async () => { const { channel, postEphemeral } = buildChannelStub({ triggeringUserId: "U_FIRST" }); const currentCaller = sessionContext({ diff --git a/packages/eve/src/public/channels/slack/defaults.ts b/packages/eve/src/public/channels/slack/defaults.ts index 48008eaa2..16cd6d74d 100644 --- a/packages/eve/src/public/channels/slack/defaults.ts +++ b/packages/eve/src/public/channels/slack/defaults.ts @@ -11,6 +11,7 @@ import { type ConnectionAuthorizationOutcome, } from "#public/channels/slack/connections.js"; import { + buildAnsweredBlocks, formatInputRequestFallbackText, renderInputRequestBlocks, } from "#public/channels/slack/hitl.js"; @@ -32,6 +33,21 @@ const log = createLogger("slack.defaults"); const REASONING_TYPING_REFRESH_INTERVAL_MS = 5_000; const REASONING_TYPING_MIN_PROGRESS_CHARS = 4; +function blockContainsRequestAction(block: unknown, requestId: string): boolean { + if (typeof block !== "object" || block === null) return false; + const candidate = block as { actions?: unknown; elements?: unknown }; + const requestActionPrefix = `eve_input:${requestId}`; + return [candidate.actions, candidate.elements].some( + (entries) => + Array.isArray(entries) && + entries.some((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const actionId = (entry as { action_id?: unknown }).action_id; + return typeof actionId === "string" && actionId.startsWith(requestActionPrefix); + }), + ); +} + /** * Workspace-scoped projection of the Slack actor that produced * `message`, derived into a {@link SessionAuthContext}. Used by both @@ -146,6 +162,67 @@ function buildInputRequestPosts( * which user overrides cannot express. */ export const defaultEvents: SlackChannelInternalEvents = { + async "approval.candidate"(event, channel, ctx) { + const currentUserId = slackUserIdFromAuthContext(ctx.session.auth.current); + if (event.outcome === "pending" && currentUserId !== undefined) { + channel.state.pendingApprovalCandidateUsers = { + ...channel.state.pendingApprovalCandidateUsers, + [event.candidateId]: currentUserId, + }; + await channel.thread.postEphemeral( + currentUserId, + "Checking whether you can approve this action…", + ); + return; + } + const mappedUserId = channel.state.pendingApprovalCandidateUsers?.[event.candidateId]; + const userId = + mappedUserId ?? + (ctx.session.auth.current?.principalId === event.responderPrincipalId + ? currentUserId + : undefined); + if (userId === undefined) return; + if (event.outcome === "rejected" || event.outcome === "failed") { + await channel.thread.postEphemeral( + userId, + event.safeReason ?? "We couldn’t verify your approval. Please try again.", + ); + const next = { ...channel.state.pendingApprovalCandidateUsers }; + delete next[event.candidateId]; + channel.state.pendingApprovalCandidateUsers = next; + } + }, + + async "approval.settled"(event, channel, _ctx) { + const cards = channel.state.pendingApprovalCards ?? {}; + const card = cards[event.requestId]; + if (card === undefined || channel.state.channelId === null) return; + const answerLabel = event.outcome === "approved" ? "Approve" : "Cancel"; + const blocks = card.messageBlocks.flatMap((block) => { + if (!blockContainsRequestAction(block, event.requestId)) return [block]; + if (typeof block !== "object" || block === null) return []; + const candidate = block as Record; + if (candidate.type !== "card") { + return buildAnsweredBlocks({ answerLabel, promptBlocks: [], userId: card.userId }); + } + const { actions: _actions, ...withoutActions } = candidate; + return buildAnsweredBlocks({ + answerLabel, + promptBlocks: [withoutActions], + userId: card.userId, + }); + }); + await channel.slack.request("chat.update", { + blocks, + channel: channel.state.channelId, + text: `Answered: ${answerLabel}`, + ts: card.messageTs, + }); + const next = { ...cards }; + delete next[event.requestId]; + channel.state.pendingApprovalCards = next; + }, + async "turn.started"(_event, channel, _ctx) { channel.state.pendingToolCallMessage = null; channel.state.lastReasoningTypingAtMs = null; @@ -239,7 +316,7 @@ export const defaultEvents: SlackChannelInternalEvents = { // the session is blocked and later see it complete. The challenge // itself remains private. const pending = channel.state.pendingAuthMessageTs ?? {}; - if (pending[event.name] === undefined) { + if (event.candidateId === undefined && pending[event.name] === undefined) { const publicText = buildAuthRequiredPublicText({ displayName, hasUser: triggeringUserId !== null, @@ -289,7 +366,7 @@ export const defaultEvents: SlackChannelInternalEvents = { async "authorization.completed"(event, channel, _ctx) { const displayName = event.authorization?.displayName ?? formatConnectionDisplayName(event.name); - if (event.outcome === "authorized") { + if (event.outcome === "authorized" && event.candidateId === undefined) { await channel.thread.startTyping(`Connected to ${displayName}. Resuming...`); } diff --git a/packages/eve/src/public/channels/slack/interactions.ts b/packages/eve/src/public/channels/slack/interactions.ts index b8ca0d119..0d575712c 100644 --- a/packages/eve/src/public/channels/slack/interactions.ts +++ b/packages/eve/src/public/channels/slack/interactions.ts @@ -42,10 +42,6 @@ import { isHitlAction, type HitlFreeformModalMetadata, } from "#public/channels/slack/hitl.js"; -import { - SLACK_CARD_SUBTEXT_MAX_LENGTH, - truncateCardSubtext, -} from "#public/channels/slack/limits.js"; import type { SlackChannelConfig, SlackChannelState, @@ -180,6 +176,23 @@ function extractActionLabel(action: Record): string | undefined return undefined; } +function buildPendingApprovalCards( + interaction: ParsedBlockActionsPayload, +): NonNullable { + const cards: NonNullable = {}; + for (const action of interaction.actions) { + const response = deriveHitlResponse(action); + if (response === null || !action.messageTs) continue; + cards[response.requestId] = { + actionId: action.actionId, + messageBlocks: interaction.messageBlocks, + messageTs: action.messageTs, + userId: action.user.id, + }; + } + return cards; +} + function findPromptBlock(blocks: readonly unknown[]): unknown { return findPromptBlocks(blocks)[0]; } @@ -207,117 +220,6 @@ function readPromptTextFromBlocks(blocks: readonly unknown[]): string | undefine return typeof text === "string" && text.length > 0 ? text : undefined; } -function buildAnsweredHitlMessageBlocks(input: { - readonly actionId: string; - readonly answerLabel: string; - readonly messageBlocks: readonly unknown[]; - readonly userId: string; -}): unknown[] { - const actionBlockIndex = findActionBlockIndex(input.messageBlocks, input.actionId); - if (actionBlockIndex === -1) { - return buildAnsweredBlocks({ - promptBlocks: findPromptBlocks(input.messageBlocks), - answerLabel: input.answerLabel, - userId: input.userId, - }); - } - - const actionBlock = input.messageBlocks[actionBlockIndex]; - const answeredBlocks = - answeredBlocksFromActionBlock({ - answerLabel: input.answerLabel, - block: actionBlock, - userId: input.userId, - }) ?? - buildAnsweredBlocks({ - promptBlocks: promptBlocksFromActionBlock(actionBlock), - answerLabel: input.answerLabel, - userId: input.userId, - }); - return [ - ...input.messageBlocks.slice(0, actionBlockIndex), - ...answeredBlocks, - ...input.messageBlocks.slice(actionBlockIndex + 1), - ]; -} - -function findActionBlockIndex(blocks: readonly unknown[], actionId: string): number { - return blocks.findIndex((block) => blockContainsActionId(block, actionId)); -} - -function blockContainsActionId(block: unknown, actionId: string): boolean { - if (!isObjectRecord(block)) return false; - return ( - actionsContainActionId(block.elements, actionId) || - actionsContainActionId(block.actions, actionId) - ); -} - -function actionsContainActionId(actions: unknown, actionId: string): boolean { - if (!Array.isArray(actions)) return false; - return actions.some((element) => isObjectRecord(element) && element.action_id === actionId); -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function answeredBlocksFromActionBlock(input: { - readonly answerLabel: string; - readonly block: unknown; - readonly userId: string; -}): unknown[] | undefined { - if (!isObjectRecord(input.block) || input.block.type !== "card") return undefined; - - const { actions: _actions, subtext: _subtext, ...blockWithoutActions } = input.block; - const answeredCard = { - ...blockWithoutActions, - subtext: { - type: "mrkdwn", - text: formatAnsweredCardSubtext(input), - verbatim: false, - }, - }; - return hasCardContent(answeredCard) ? [answeredCard] : undefined; -} - -const ANSWERED_CARD_SUBTEXT_PREFIX = ":white_check_mark: *"; -const ANSWERED_CARD_SUBTEXT_SUFFIX = "*"; - -function formatAnsweredCardSubtext(input: { - readonly answerLabel: string; - readonly userId: string; -}): string { - const attribution = input.userId.length > 0 ? ` by <@${input.userId}>` : ""; - const labelBudget = - SLACK_CARD_SUBTEXT_MAX_LENGTH - - ANSWERED_CARD_SUBTEXT_PREFIX.length - - ANSWERED_CARD_SUBTEXT_SUFFIX.length - - attribution.length; - const label = truncateWithEllipsis(input.answerLabel, labelBudget); - return truncateCardSubtext( - `${ANSWERED_CARD_SUBTEXT_PREFIX}${label}${ANSWERED_CARD_SUBTEXT_SUFFIX}${attribution}`, - ); -} - -function truncateWithEllipsis(value: string, maxLength: number): string { - if (maxLength <= 0) return ""; - if (value.length <= maxLength) return value; - const sliceLength = Math.max(0, maxLength - 3); - return `${value.slice(0, sliceLength).trimEnd()}...`; -} - -function promptBlocksFromActionBlock(block: unknown): unknown[] { - if (!isObjectRecord(block) || block.type !== "card") return []; - - const { actions: _actions, ...blockWithoutActions } = block; - return hasCardContent(blockWithoutActions) ? [blockWithoutActions] : []; -} - -function hasCardContent(block: Record): boolean { - return block.body !== undefined || block.title !== undefined || block.hero_image !== undefined; -} - /** * Channel-supplied dependencies for {@link handleInteractionPost}. * @@ -385,7 +287,7 @@ export async function handleInteractionPost( ctx.waitUntil( ctx .send( - { inputResponses }, + { inputResponses, pendingApprovalCards: buildPendingApprovalCards(interaction) }, { auth: buildSlackAuthContext({ channelId: interaction.channelId, @@ -400,6 +302,7 @@ export async function handleInteractionPost( threadTs: interaction.threadTs, teamId: interaction.teamId ?? null, triggeringUserId: user.id, + pendingApprovalCards: buildPendingApprovalCards(interaction), }, }, ) @@ -407,12 +310,6 @@ export async function handleInteractionPost( log.error("HITL interaction delivery failed", { error }); }), ); - - ctx.waitUntil( - updateAnsweredHitlCard(interaction, deps).catch((error: unknown) => { - log.error("HITL answered-card update failed", { error }); - }), - ); } const onInteraction = deps.config.onInteraction; @@ -589,42 +486,6 @@ async function handleViewSubmission( return ack; } -async function updateAnsweredHitlCard( - interaction: ParsedBlockActionsPayload, - deps: InteractionHandlerDeps, -): Promise { - const hitlAction = interaction.actions.find((a) => isHitlAction(a.actionId)); - if (!hitlAction || !hitlAction.messageTs) return; - - const answerLabel = hitlAction.label ?? hitlAction.selectedOptionValue ?? hitlAction.value; - if (!answerLabel) return; - - const blocks = buildAnsweredHitlMessageBlocks({ - actionId: hitlAction.actionId, - answerLabel, - messageBlocks: interaction.messageBlocks, - userId: hitlAction.user.id, - }); - - const token = await resolveSlackBotToken(deps.config.credentials?.botToken); - const response = await fetch("https://slack.com/api/chat.update", { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - channel: interaction.channelId, - ts: hitlAction.messageTs, - blocks, - text: `Answered: ${answerLabel}`, - }), - }); - if (!response.ok) { - throw new Error(`Slack chat.update returned HTTP ${response.status}`); - } -} - async function updateAnsweredFreeformCard(input: { readonly channelId: string; readonly messageTs: string; diff --git a/packages/eve/src/public/channels/slack/slackChannel.test.ts b/packages/eve/src/public/channels/slack/slackChannel.test.ts index a8dc1f0a4..f936c00a3 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.test.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.test.ts @@ -18,7 +18,6 @@ import { } from "#public/channels/slack/hitl.js"; import { SLACK_CARD_BODY_TEXT_MAX_LENGTH, - SLACK_CARD_SUBTEXT_MAX_LENGTH, SLACK_MAX_BLOCKS_PER_MESSAGE, SLACK_MESSAGE_TEXT_MAX_LENGTH, SLACK_SECTION_TEXT_MAX_LENGTH, @@ -2180,8 +2179,9 @@ describe("slackChannel() HITL interaction pipeline", () => { expect(send).toHaveBeenCalledTimes(1); const [payload, options] = send.mock.calls[0]!; - expect(payload).toEqual({ + expect(payload).toMatchObject({ inputResponses: [{ optionId: "approve", requestId: "approval_abc123" }], + pendingApprovalCards: { approval_abc123: expect.objectContaining({ userId: "U_APPROVER" }) }, }); expect(options).toMatchObject({ auth: { @@ -2307,47 +2307,14 @@ describe("slackChannel() HITL interaction pipeline", () => { ); expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]).toEqual({ + expect(send.mock.calls[0]?.[0]).toMatchObject({ inputResponses: [{ optionId: "approve", requestId: "approval_451" }], + pendingApprovalCards: { approval_451: expect.any(Object) }, }); - const updateCall = fetchMock.mock.calls.find( - ([url]) => String(url) === "https://slack.com/api/chat.update", - ); - expect(updateCall).toBeDefined(); - const body = parseSlackRequestBody(updateCall?.[1] as RequestInit) as { - blocks: Array<{ - actions?: Array<{ action_id?: string }>; - elements?: Array<{ action_id?: string }>; - subtext?: { text?: string }; - text?: { text?: string }; - }>; - channel: string; - text: string; - ts: string; - }; - - expect(body).toMatchObject({ - channel: "C01", - text: "Answered: Approve", - ts: "1700000000.000010", - }); - expect(JSON.stringify(body.blocks)).toContain("Approve issue 451?"); - expect(JSON.stringify(body.blocks)).toContain("Approve"); - expect(JSON.stringify(body.blocks)).toContain("Tool input"); - expect(JSON.stringify(body.blocks)).toContain("Approve issue 508?"); - expect(body.blocks[0]?.subtext?.text).toBe(":white_check_mark: *Approve* by <@U_APPROVER>"); - expect(body.blocks[0]?.subtext?.text?.length).toBeLessThanOrEqual( - SLACK_CARD_SUBTEXT_MAX_LENGTH, - ); - - const remainingActionIds = body.blocks.flatMap( - (block) => - (block.actions ?? block.elements) - ?.map((element) => element.action_id) - .filter((actionId): actionId is string => typeof actionId === "string") ?? [], - ); - expect(remainingActionIds).toEqual([secondDenyActionId, secondApproveActionId]); + expect( + fetchMock.mock.calls.find(([url]) => String(url) === "https://slack.com/api/chat.update"), + ).toBeUndefined(); }); it("covers the observed e0 batched escalation approval run", async () => { @@ -2453,36 +2420,14 @@ describe("slackChannel() HITL interaction pipeline", () => { ); expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0]?.[0]).toEqual({ + expect(send.mock.calls[0]?.[0]).toMatchObject({ inputResponses: [{ optionId: "cancel", requestId: "approval_451" }], + pendingApprovalCards: { approval_451: expect.any(Object) }, }); - const updateCall = fetchMock.mock.calls.find( - ([url]) => String(url) === "https://slack.com/api/chat.update", - ); - expect(updateCall).toBeDefined(); - const body = parseSlackRequestBody(updateCall?.[1] as RequestInit) as { - blocks: Array<{ - actions?: Array<{ action_id?: string }>; - child_blocks?: Array<{ text?: { text?: string } }>; - elements?: Array<{ action_id?: string }>; - subtext?: { text?: string }; - }>; - }; - - expect(body.blocks[0]?.subtext?.text).toBe(":white_check_mark: *Cancel* by <@U0AT7H56S90>"); - expect(body.blocks[1]?.child_blocks?.[0]?.text?.text).toContain('"issueNumber": 451'); - expect(body.blocks[3]?.child_blocks?.[0]?.text?.text).toContain('"issueNumber": 508'); - const remainingActionIds = body.blocks.flatMap( - (block) => - (block.actions ?? block.elements) - ?.map((element) => element.action_id) - .filter((actionId): actionId is string => typeof actionId === "string") ?? [], - ); - expect(remainingActionIds).toEqual([ - `${HITL_ACTION_PREFIX}approval_508:button:0`, - `${HITL_ACTION_PREFIX}approval_508:button:1`, - ]); + expect( + fetchMock.mock.calls.find(([url]) => String(url) === "https://slack.com/api/chat.update"), + ).toBeUndefined(); }); it("resumes freeform modal answers with the submitting Slack user auth", async () => { diff --git a/packages/eve/src/public/channels/slack/slackChannel.ts b/packages/eve/src/public/channels/slack/slackChannel.ts index e64566e71..aa6a8e932 100644 --- a/packages/eve/src/public/channels/slack/slackChannel.ts +++ b/packages/eve/src/public/channels/slack/slackChannel.ts @@ -1,5 +1,6 @@ import { parseSlackWebhookBody } from "#compiled/@chat-adapter/slack/webhook.js"; +import { defaultDeliverResult } from "#channel/adapter.js"; import type { CrossChannelReceiveOptions } from "#channel/cross-channel-receive.js"; import type { Session, SessionHandle } from "#channel/session.js"; import type { CancelTurnResult, SessionAuthContext } from "#channel/types.js"; @@ -8,7 +9,7 @@ import type { SessionContext } from "#public/definitions/callback-context.js"; import type { ChannelSessionOps } from "#public/definitions/channel.js"; import { createLogger, logError } from "#internal/logging.js"; -import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; import { buildSlackBinding, buildSlackWorkspaceHandle, @@ -69,8 +70,8 @@ import { markEventHandled } from "./utils.js"; const log = createLogger("slack.channel"); -type EventData = - Extract extends { data: infer D } ? D : undefined; +type EventData = + Extract extends { data: infer D } ? D : undefined; /** * Base Slack context for inbound webhook handlers. These hooks run before the @@ -111,7 +112,7 @@ export type { } from "#public/channels/slack/api.js"; export type { SlackWebhookVerifier } from "#public/channels/slack/verify.js"; -type SlackEventHandler = ( +type SlackEventHandler = ( data: EventData, channel: SlackEventContext, ctx: SessionContext, @@ -167,6 +168,13 @@ type SlackSessionFailedHandler = ( * step boundaries. Anything written here must round-trip through * `JSON.stringify` / `JSON.parse`. */ +export interface SlackPendingApprovalCard { + readonly actionId: string; + readonly messageBlocks: readonly unknown[]; + readonly messageTs: string; + readonly userId: string; +} + export interface SlackChannelState { /** Slack channel id seeded by the inbound mention. */ channelId: string | null; @@ -204,6 +212,8 @@ export interface SlackChannelState { * resolution outcome. */ pendingAuthMessageTs?: Record; + pendingApprovalCards?: Record; + pendingApprovalCandidateUsers?: Record; } /** @@ -448,6 +458,8 @@ export type SlackInboundResultOrPromise = SlackMentionResultOrPromise; * {@link SessionContext}; `session.failed` receives only data and context. */ export interface SlackChannelEvents { + readonly "approval.candidate"?: SlackEventHandler<"approval.candidate">; + readonly "approval.settled"?: SlackEventHandler<"approval.settled">; readonly "turn.started"?: SlackEventHandler<"turn.started">; readonly "actions.requested"?: SlackEventHandler<"actions.requested">; readonly "action.result"?: SlackEventHandler<"action.result">; @@ -686,6 +698,8 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel { lastReasoningTypingAtMs: null, lastReasoningTypingStatus: null, pendingAuthMessageTs: {}, + pendingApprovalCards: {}, + pendingApprovalCandidateUsers: {}, }, fetchFile: slackFetchFile, metadata(state): SlackInstrumentationMetadata { @@ -701,6 +715,17 @@ export function slackChannel(config: SlackChannelConfig = {}): SlackChannel { return rebuildSlackContext(state, session, config.credentials); }, + deliver(payload, channel) { + const incoming = payload["pendingApprovalCards"]; + if (typeof incoming === "object" && incoming !== null) { + channel.state.pendingApprovalCards = { + ...channel.state.pendingApprovalCards, + ...(incoming as SlackChannelState["pendingApprovalCards"]), + }; + } + return defaultDeliverResult(payload); + }, + routes: [ POST( config.route ?? SLACK_CHANNEL_DEFAULT_ROUTE, diff --git a/packages/eve/src/public/definitions/channel.test.ts b/packages/eve/src/public/definitions/channel.test.ts index a2e961d3b..252ed7d0d 100644 --- a/packages/eve/src/public/definitions/channel.test.ts +++ b/packages/eve/src/public/definitions/channel.test.ts @@ -268,6 +268,46 @@ describe("defineChannel", () => { }); }); + it("runs authored deliver behavior with the live context", async () => { + const channel = defineChannel({ + state: { seen: [] as string[] }, + context(state) { + return { + record(value: string) { + state.seen.push(value); + }, + }; + }, + deliver(payload, ctx) { + ctx.record(String(payload["marker"])); + return { inputResponses: payload.inputResponses }; + }, + routes: [POST("/x", async () => new Response("ok"))], + }); + const adapter = getAdapter(channel); + const session: Session = { + auth: { current: null, initiator: null }, + sessionId: "sess-deliver-test", + turn: { id: "turn-1", sequence: 0 }, + }; + const context = new ContextContainer(); + context.set(SessionKey, session); + const adapterCtx = buildAdapterContext(adapter, context); + + const result = await adapter.deliver?.( + { + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + marker: "card-1", + }, + adapterCtx, + ); + + expect(result).toEqual({ + inputResponses: [{ optionId: "approve", requestId: "approval-1" }], + }); + expect(adapterCtx.state).toEqual({ seen: ["card-1"] }); + }); + it("preserves state + context + events when fetchFile is also declared", () => { const fetchFile = async (_url: string) => { return Buffer.alloc(0); diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index 7e0b09579..b064f2139 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -236,6 +236,8 @@ type ChannelSessionFailedHandler = ( * and the channel context, with no `ctx`. */ export interface ChannelEvents { + readonly "approval.candidate"?: ChannelEventHandler<"approval.candidate", TCtx>; + readonly "approval.settled"?: ChannelEventHandler<"approval.settled", TCtx>; readonly "turn.started"?: ChannelEventHandler<"turn.started", TCtx>; readonly "actions.requested"?: ChannelEventHandler<"actions.requested", TCtx>; readonly "action.result"?: ChannelEventHandler<"action.result", TCtx>; @@ -339,6 +341,8 @@ export function defineChannel< // The Record type fails to compile if this map drifts from the ChannelEvents // keys in either direction. const channelEventTypes: Record = { + "approval.candidate": null, + "approval.settled": null, "turn.started": null, "actions.requested": null, "action.result": null, @@ -429,8 +433,9 @@ function buildAdapter = Extract< - MessageStreamEvent, +type ProtocolEvent = Extract< + HandleMessageStreamEvent, { type: TType } >; @@ -17,6 +16,8 @@ type ProtocolEvent = Extract< */ export interface HookEventMap { readonly "action.result": ProtocolEvent<"action.result">; + readonly "approval.candidate": ProtocolEvent<"approval.candidate">; + readonly "approval.settled": ProtocolEvent<"approval.settled">; readonly "actions.requested": ProtocolEvent<"actions.requested">; readonly "authorization.completed": ProtocolEvent<"authorization.completed">; readonly "authorization.required": ProtocolEvent<"authorization.required">; diff --git a/packages/eve/src/shared/channel-definition.ts b/packages/eve/src/shared/channel-definition.ts index 043c9970c..42543a5b0 100644 --- a/packages/eve/src/shared/channel-definition.ts +++ b/packages/eve/src/shared/channel-definition.ts @@ -1,7 +1,8 @@ import { type ChannelCors } from "#channel/cors.js"; import type { RouteDefinition, SendFn } from "#channel/routes.js"; import type { Session, SessionHandle } from "#channel/session.js"; -import type { SessionAuthContext } from "#channel/types.js"; +import type { DeliverPayload, SessionAuthContext } from "#channel/types.js"; +import type { StepInput } from "#harness/types.js"; /** * Enriched return shape from a channel's {@link ChannelAdapter.fetchFile} @@ -70,6 +71,7 @@ export interface GenericChannelDefinition< context?(state: NonNullable, session: SessionHandle): TCtx; readonly routes: readonly RouteDefinition[]; + deliver?(payload: DeliverPayload, ctx: TCtx): StepInput | void | Promise; receive?( input: GenericReceiveInput, args: { send: SendFn },