diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index fc12301768764..a96a14674665e 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -62,6 +62,7 @@ openclaw hooks info session-memory | `message:transcribed` | After audio transcription completes | | `message:preprocessed` | After media and link preprocessing completes or is skipped | | `message:sent` | Outbound message delivered | +| `message:feedback` | Channel-native reaction or written feedback | ## Writing hooks @@ -131,9 +132,11 @@ reply channel and ignore pushed messages. **Command events** (`command:new`, `command:reset`): `context.sessionEntry`, `context.previousSessionEntry`, `context.commandSource`, `context.workspaceDir`, `context.cfg`. -**Message events** (`message:received`): `context.from`, `context.content`, `context.channelId`, `context.metadata` (provider-specific data including `senderId`, `senderName`, `guildId`). `context.content` prefers a nonblank command body for command-like messages, then falls back to the raw inbound body and generic body; it does not include agent-only enrichment such as thread history or link summaries. +**Message events** (`message:received`): `context.from`, `context.content`, `context.channelId`, `context.senderId`, `context.replyToId`, `context.metadata` (provider-specific data including `senderName`, `guildId`). `context.content` prefers a nonblank command body for command-like messages, then falls back to the raw inbound body and generic body; it does not include agent-only enrichment such as thread history or link summaries. -**Message events** (`message:sent`): `context.to`, `context.content`, `context.success`, `context.channelId`. +**Message events** (`message:sent`): `context.to`, `context.content`, `context.success`, `context.channelId`, and, when the channel can bind delivery to an agent turn, `context.runId`. + +**Message events** (`message:feedback`): `context.kind`, `context.providerEventId`, `context.providerActivityId`, `context.providerConversationId`, `context.providerTargetActivityId`, `context.actorId`, and optional `context.reaction` or `context.content`. `providerEventId` distinguishes logical facts when one provider activity carries both a reaction and comment. These provider fields are untrusted input; hooks must validate, bound, and redact them before persistence or command use. The event is emitted only by channels with native feedback support. **Message events** (`message:transcribed`): `context.transcript`, `context.from`, `context.channelId`, `context.mediaPath`. diff --git a/extensions/msteams/src/feedback-invoke.ts b/extensions/msteams/src/feedback-invoke.ts index 0f65cd8cdf7aa..b1f05c8ed2cfc 100644 --- a/extensions/msteams/src/feedback-invoke.ts +++ b/extensions/msteams/src/feedback-invoke.ts @@ -6,6 +6,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe import { formatUnknownError } from "./errors.js"; import { buildFeedbackEvent, runFeedbackReflection } from "./feedback-reflection.js"; import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js"; +import { emitMSTeamsFeedbackHook, normalizeMSTeamsHookTimestamp } from "./message-hooks.js"; import { isFeedbackInvokeAuthorized } from "./monitor-handler.js"; import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -131,6 +132,40 @@ export async function runMSTeamsFeedbackInvokeHandler( hasComment: Boolean(userComment), }); + const exactFeedbackIds = + activity.id && + conversationId !== "unknown" && + senderId !== "unknown" && + messageId !== "unknown"; + if (exactFeedbackIds) { + emitMSTeamsFeedbackHook({ + sessionKey: route.sessionKey, + accountId: route.accountId, + kind: "reaction_added", + occurredAt: normalizeMSTeamsHookTimestamp(activity.timestamp), + providerEventId: `${activity.id}:reaction:${reaction}`, + providerActivityId: activity.id, + providerConversationId: conversationId, + providerTargetActivityId: messageId, + actorId: senderId, + reaction, + }); + if (userComment) { + emitMSTeamsFeedbackHook({ + sessionKey: route.sessionKey, + accountId: route.accountId, + kind: "feedback_comment", + occurredAt: normalizeMSTeamsHookTimestamp(activity.timestamp), + providerEventId: `${activity.id}:comment`, + providerActivityId: activity.id, + providerConversationId: conversationId, + providerTargetActivityId: messageId, + actorId: senderId, + content: userComment, + }); + } + } + // Write feedback event to session transcript try { const storePath = core.channel.session.resolveStorePath(deps.cfg.session?.store, { diff --git a/extensions/msteams/src/message-hooks.test.ts b/extensions/msteams/src/message-hooks.test.ts new file mode 100644 index 0000000000000..6842b4c16e31c --- /dev/null +++ b/extensions/msteams/src/message-hooks.test.ts @@ -0,0 +1,116 @@ +// Msteams tests cover canonical message and feedback hook emission. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runMessageSent: vi.fn(async () => undefined), + triggerInternalHook: vi.fn(async () => undefined), +})); + +vi.mock("openclaw/plugin-sdk/hook-runtime", () => ({ + buildCanonicalSentMessageHookContext: (params: Record) => params, + createInternalHookEvent: ( + type: string, + action: string, + sessionKey: string, + context: Record, + ) => ({ type, action, sessionKey, context }), + fireAndForgetHook: (promise: Promise) => void promise, + toInternalMessageSentContext: (canonical: Record) => canonical, + toPluginMessageContext: (canonical: Record) => canonical, + toPluginMessageSentEvent: (canonical: Record) => canonical, + triggerInternalHook: mocks.triggerInternalHook, +})); + +vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ + getGlobalHookRunner: () => ({ + hasHooks: (name: string) => name === "message_sent", + runMessageSent: mocks.runMessageSent, + }), +})); + +import { emitMSTeamsFeedbackHook, emitMSTeamsMessageSentHooks } from "./message-hooks.js"; + +describe("msteams message hooks", () => { + beforeEach(() => { + mocks.runMessageSent.mockClear(); + mocks.triggerInternalHook.mockClear(); + }); + + it("binds an exact provider response id to the agent run", async () => { + emitMSTeamsMessageSentHooks({ + sessionKey: "agent:ops:msteams:direct:user", + runId: "run-123", + to: "user:user", + accountId: "ops", + conversationId: "conversation-123", + content: "Response", + messageId: "activity-456", + isGroup: false, + }); + await vi.waitFor(() => expect(mocks.runMessageSent).toHaveBeenCalledOnce()); + + expect(mocks.runMessageSent.mock.calls[0]?.[0]).toMatchObject({ + channelId: "msteams", + conversationId: "conversation-123", + messageId: "activity-456", + runId: "run-123", + }); + expect(mocks.triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sent", + sessionKey: "agent:ops:msteams:direct:user", + }), + ); + }); + + it("marks provider feedback fields and content as untrusted", async () => { + emitMSTeamsFeedbackHook({ + sessionKey: "agent:ops:msteams:direct:user", + kind: "feedback_comment", + providerEventId: "feedback-123:comment", + providerActivityId: "feedback-123", + providerConversationId: "conversation-123", + providerTargetActivityId: "activity-456", + actorId: "00000000-0000-0000-0000-000000000001", + content: "", + }); + await vi.waitFor(() => expect(mocks.triggerInternalHook).toHaveBeenCalledOnce()); + + expect(mocks.triggerInternalHook).toHaveBeenCalledWith( + expect.objectContaining({ + action: "feedback", + context: expect.objectContaining({ + providerActivityId: "feedback-123", + providerEventId: "feedback-123:comment", + providerTargetActivityId: "activity-456", + content: "", + untrusted: true, + }), + }), + ); + }); + + it("fails closed when exact response or feedback identifiers are absent", () => { + emitMSTeamsMessageSentHooks({ + sessionKey: "agent:ops:msteams:direct:user", + to: "user:user", + conversationId: "conversation-123", + content: "Response", + messageId: "unknown", + isGroup: false, + }); + emitMSTeamsFeedbackHook({ + sessionKey: "agent:ops:msteams:direct:user", + kind: "feedback_comment", + providerEventId: "feedback-123:comment", + providerActivityId: "feedback-123", + providerConversationId: "conversation-123", + providerTargetActivityId: "unknown", + actorId: "00000000-0000-0000-0000-000000000001", + content: "ambiguous", + }); + + expect(mocks.runMessageSent).not.toHaveBeenCalled(); + expect(mocks.triggerInternalHook).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/msteams/src/message-hooks.ts b/extensions/msteams/src/message-hooks.ts new file mode 100644 index 0000000000000..e134b94e73af4 --- /dev/null +++ b/extensions/msteams/src/message-hooks.ts @@ -0,0 +1,125 @@ +// Msteams plugin module emits canonical outbound and feedback hook facts. +import { + buildCanonicalSentMessageHookContext, + createInternalHookEvent, + fireAndForgetHook, + toInternalMessageSentContext, + toPluginMessageContext, + toPluginMessageSentEvent, + triggerInternalHook, +} from "openclaw/plugin-sdk/hook-runtime"; +import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; + +export type MSTeamsMessageSentHookParams = { + sessionKey: string; + runId?: string; + to: string; + accountId?: string; + conversationId: string; + content: string; + messageId: string; + isGroup: boolean; + groupId?: string; +}; + +export function emitMSTeamsMessageSentHooks(params: MSTeamsMessageSentHookParams): void { + if ( + !params.runId?.trim() || + !params.messageId.trim() || + params.messageId === "unknown" || + !params.conversationId.trim() + ) { + return; + } + const canonical = buildCanonicalSentMessageHookContext({ + ...params, + channelId: "msteams", + success: true, + }); + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("message_sent")) { + fireAndForgetHook( + Promise.resolve( + hookRunner.runMessageSent( + toPluginMessageSentEvent(canonical), + toPluginMessageContext(canonical), + ), + ), + "msteams: message_sent plugin hook failed", + ); + } + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent( + "message", + "sent", + params.sessionKey, + toInternalMessageSentContext(canonical), + ), + ), + "msteams: message:sent internal hook failed", + ); +} + +export type MSTeamsFeedbackHookKind = "reaction_added" | "reaction_removed" | "feedback_comment"; + +export type MSTeamsFeedbackHookParams = { + sessionKey: string; + accountId?: string; + kind: MSTeamsFeedbackHookKind; + occurredAt?: string; + /** Stable id for this logical sub-event; distinct from its provider activity id. */ + providerEventId: string; + providerActivityId: string; + providerConversationId: string; + providerTargetActivityId: string; + actorId: string; + reaction?: string; + content?: string; +}; + +export function normalizeMSTeamsHookTimestamp(value: unknown): string | undefined { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.toISOString(); + } + if (typeof value === "string" && !Number.isNaN(Date.parse(value))) { + return value; + } + return undefined; +} + +/** + * Emit an untrusted provider fact. Consumers must validate, bound, and redact it. + * Missing provider activity ids are preserved as absent rather than invented. + */ +export function emitMSTeamsFeedbackHook(params: MSTeamsFeedbackHookParams): void { + const exactReferences = [ + params.providerEventId, + params.providerActivityId, + params.providerConversationId, + params.providerTargetActivityId, + params.actorId, + ]; + if (exactReferences.some((value) => !value?.trim() || value === "unknown")) { + return; + } + fireAndForgetHook( + triggerInternalHook( + createInternalHookEvent("message", "feedback", params.sessionKey, { + channelId: "msteams", + accountId: params.accountId, + kind: params.kind, + occurredAt: params.occurredAt, + providerEventId: params.providerEventId, + providerActivityId: params.providerActivityId, + providerConversationId: params.providerConversationId, + providerTargetActivityId: params.providerTargetActivityId, + actorId: params.actorId, + reaction: params.reaction, + content: params.content, + untrusted: true, + }), + ), + "msteams: message:feedback internal hook failed", + ); +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 09091b7f5867d..7fa2a4734f4e6 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -303,6 +303,7 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; + const onSent = vi.fn(); const ctx = { sendActivity: createRecordedSendActivity(sent), }; @@ -313,10 +314,34 @@ describe("msteams messenger", () => { conversationRef: baseRef, context: ctx, messages: [{ text: "one" }, { text: "two" }], + onSent, }); expect(sent).toEqual(["one", "two"]); expect(ids).toEqual(["id:one", "id:two"]); + expect(onSent.mock.calls).toEqual([ + [{ messageId: "id:one", message: { text: "one" } }], + [{ messageId: "id:two", message: { text: "two" } }], + ]); + }); + + it("does not retry a confirmed send when an observation callback throws", async () => { + const sendActivity = vi.fn(async () => ({ id: "id:one" })); + + const ids = await sendMSTeamsMessages({ + replyStyle: "thread", + app: createMockApp(), + appId: "app123", + conversationRef: baseRef, + context: { sendActivity }, + messages: [{ text: "one" }], + onSent: () => { + throw new Error("observer failed"); + }, + }); + + expect(ids).toEqual(["id:one"]); + expect(sendActivity).toHaveBeenCalledOnce(); }); it("sends top-level messages via proactive send context", async () => { diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 030dc0d1b23ed..155620ce9d382 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -425,6 +425,8 @@ export async function sendMSTeamsMessages(params: { /** Enable the Teams feedback loop (thumbs up/down) on sent messages. */ feedbackLoopEnabled?: boolean; serviceUrlBoundary?: MSTeamsSdkCloudOptions; + /** Observe each provider-confirmed activity without changing the return contract. */ + onSent?: (sent: { messageId: string; message: MSTeamsRenderedMessage }) => void; }): Promise { const messages = params.messages.filter( (m) => (m.text && m.text.trim().length > 0) || m.mediaUrl, @@ -507,6 +509,14 @@ export async function sendMSTeamsMessages(params: { }, ); const messageId = extractMessageId(response) ?? "unknown"; + if (messageId !== "unknown") { + try { + params.onSent?.({ messageId, message }); + } catch { + // Observability callbacks must never turn a provider-confirmed send into + // a retry, which could duplicate the user-visible message. + } + } // Store the activity ID so the accept handler can replace the consent card in-place if (pendingUploadId && messageId !== "unknown") { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 60bdd80644fac..7292bff1f39f0 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -46,6 +46,7 @@ import { translateMSTeamsDmConversationIdForGraph, wasMSTeamsBotMentioned, } from "../inbound.js"; +import { emitMSTeamsMessageSentHooks } from "../message-hooks.js"; import { fetchParentMessageCached, formatParentContextEvent, @@ -845,10 +846,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { context, replyStyle, textLimit, - onSentMessageIds: (ids) => { - for (const id of ids) { - recordMSTeamsSentMessage(conversationId, id); - } + onSentMessage: (id, message, runId) => { + recordMSTeamsSentMessage(conversationId, id); + emitMSTeamsMessageSentHooks({ + sessionKey: route.sessionKey, + runId, + to: teamsTo, + accountId: route.accountId, + conversationId, + content: message.text ?? "", + messageId: id, + isGroup: !isDirectMessage, + groupId: isDirectMessage ? undefined : conversationId, + }); }, tokenProvider, sharePointSiteId, diff --git a/extensions/msteams/src/monitor-handler/reaction-handler.ts b/extensions/msteams/src/monitor-handler/reaction-handler.ts index 328c1815caa6a..9a62b2b3b108e 100644 --- a/extensions/msteams/src/monitor-handler/reaction-handler.ts +++ b/extensions/msteams/src/monitor-handler/reaction-handler.ts @@ -1,5 +1,6 @@ // Msteams plugin module implements reaction handler behavior. import { normalizeMSTeamsConversationId } from "../inbound.js"; +import { emitMSTeamsFeedbackHook, normalizeMSTeamsHookTimestamp } from "../message-hooks.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js"; import { getMSTeamsRuntime } from "../runtime.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; @@ -98,7 +99,7 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { // The replyToId points to the message that was reacted to. const targetMessageId = (activity as unknown as { replyToId?: string }).replyToId ?? "unknown"; - for (const reaction of reactions) { + for (const [reactionIndex, reaction] of reactions.entries()) { const reactionType = reaction.type ?? "unknown"; const emoji = mapReactionEmoji(reactionType); const label = @@ -118,6 +119,25 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { sessionKey: route.sessionKey, contextKey: `msteams:reaction:${conversationId}:${targetMessageId}:${senderId}:${reactionType}:${direction}`, }); + if ( + activity.id && + conversationId && + targetMessageId !== "unknown" && + reactionType !== "unknown" + ) { + emitMSTeamsFeedbackHook({ + sessionKey: route.sessionKey, + accountId: route.accountId, + kind: direction === "added" ? "reaction_added" : "reaction_removed", + occurredAt: normalizeMSTeamsHookTimestamp(activity.timestamp), + providerEventId: `${activity.id}:${direction}:${reactionType}:${reactionIndex}`, + providerActivityId: activity.id, + providerConversationId: conversationId, + providerTargetActivityId: targetMessageId, + actorId: senderId, + reaction: reactionType, + }); + } } }; } diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index 3f6b19325b2b6..834047fb590bc 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -163,6 +163,7 @@ describe("createMSTeamsReplyDispatcher", () => { } type DispatcherOptions = { + onAgentRunStart?: (runId: string) => void; onReplyStart?: () => Promise | void; deliver: (payload: { text: string }) => Promise | void; }; @@ -583,6 +584,50 @@ describe("createMSTeamsReplyDispatcher", () => { expect(sendMSTeamsMessagesMock).toHaveBeenCalledTimes(1); }); + it("binds each provider-confirmed message to the active run", async () => { + const message = { text: "hello" }; + const onSentMessage = vi.fn(); + renderReplyPayloadsToMessagesMock.mockReturnValue([message] as never); + sendMSTeamsMessagesMock.mockImplementationOnce(async (params) => { + params.onSent?.({ messageId: "id-1", message }); + return ["id-1"]; + }); + + createDispatcher("personal", { blockStreaming: true }, { onSentMessage }); + const options = dispatcherOptions(); + options.onAgentRunStart?.("run-123"); + await options.deliver({ text: "block content" }); + + expect(onSentMessage).toHaveBeenCalledWith("id-1", message, "run-123"); + }); + + it("keeps the originating run bound across a delayed multi-run flush", async () => { + const onSentMessage = vi.fn(); + renderReplyPayloadsToMessagesMock.mockImplementation((payloads) => [ + { text: payloads[0]?.text }, + ]); + sendMSTeamsMessagesMock.mockImplementationOnce(async (params) => { + for (const message of params.messages) { + const messageId = `id:${message.text}`; + params.onSent?.({ messageId, message }); + } + return params.messages.map((message) => `id:${message.text}`); + }); + + const dispatcher = createDispatcher("personal", { blockStreaming: false }, { onSentMessage }); + const options = dispatcherOptions(); + options.onAgentRunStart?.("run-1"); + await options.deliver({ text: "one" }); + options.onAgentRunStart?.("run-2"); + await options.deliver({ text: "two" }); + await dispatcher.markDispatchIdle(); + + expect(onSentMessage.mock.calls).toEqual([ + ["id:one", { text: "one" }, "run-1"], + ["id:two", { text: "two" }, "run-2"], + ]); + }); + it("does not flush messages on deliver when blockStreaming is disabled", async () => { renderReplyPayloadsToMessagesMock.mockReturnValue([{ content: "hello" }] as never); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 1ccaaaff6fcbe..8fbb56cb4ecca 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -55,6 +55,7 @@ export function createMSTeamsReplyDispatcher(params: { replyStyle: MSTeamsReplyStyle; textLimit: number; onSentMessageIds?: (ids: string[]) => void; + onSentMessage?: (id: string, message: MSTeamsRenderedMessage, runId?: string) => void; tokenProvider?: MSTeamsAccessTokenProvider; sharePointSiteId?: string; }) { @@ -190,16 +191,19 @@ export function createMSTeamsReplyDispatcher(params: { const typingIndicatorEnabled = typeof msteamsCfg?.typingIndicator === "boolean" ? msteamsCfg.typingIndicator : true; - const pendingMessages: MSTeamsRenderedMessage[] = []; + type PendingMessage = { message: MSTeamsRenderedMessage; runId?: string }; + const pendingMessages: PendingMessage[] = []; + let activeRunId: string | undefined; - const sendMessages = async (messages: MSTeamsRenderedMessage[]): Promise => { + const sendMessages = async (pending: PendingMessage[]): Promise => { + const runIdByMessage = new Map(pending.map((entry) => [entry.message, entry.runId])); return sendMSTeamsMessages({ replyStyle: params.replyStyle, app: params.app, appId: params.appId, conversationRef: params.conversationRef, context: params.context, - messages, + messages: pending.map((entry) => entry.message), retry: {}, onRetry: (event) => { params.log.debug?.("retrying send", { @@ -212,6 +216,9 @@ export function createMSTeamsReplyDispatcher(params: { mediaMaxBytes, feedbackLoopEnabled, serviceUrlBoundary: resolveMSTeamsSdkCloudOptions(msteamsCfg), + onSent: ({ messageId, message }) => { + params.onSentMessage?.(messageId, message, runIdByMessage.get(message)); + }, }); }; @@ -249,7 +256,7 @@ export function createMSTeamsReplyDispatcher(params: { tableMode, chunkMode, }); - pendingMessages.push(...messages); + pendingMessages.push(...messages.map((message) => ({ message, runId: activeRunId }))); }; const flushPendingMessages = async () => { @@ -265,9 +272,9 @@ export function createMSTeamsReplyDispatcher(params: { ids = []; let failed = 0; let lastFailedError: unknown = batchError; - for (const msg of toSend) { + for (const pending of toSend) { try { - const msgIds = await sendMessages([msg]); + const msgIds = await sendMessages([pending]); ids.push(...msgIds); } catch (msgError) { failed += 1; @@ -299,6 +306,9 @@ export function createMSTeamsReplyDispatcher(params: { } = core.channel.reply.createReplyDispatcherWithTyping({ ...replyPipeline, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + onAgentRunStart: (runId) => { + activeRunId = runId; + }, onReplyStart: async () => { await streamController.onReplyStart(); // Always start the typing keepalive loop when typing is enabled and diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 965089fda6c7c..3b1a7c37fe7a9 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -123,6 +123,8 @@ export type ReplyDispatcherOptions = { export type ReplyDispatcherWithTypingOptions = Omit & { typingCallbacks?: TypingCallbacks; + /** Observe the concrete run id assigned to this reply turn. */ + onAgentRunStart?: (runId: string) => void; onReplyStart?: () => Promise | void; onIdle?: () => Promise | void; onSettled?: () => unknown; @@ -133,7 +135,10 @@ export type ReplyDispatcherWithTypingOptions = Omit; + replyOptions: Pick< + GetReplyOptions, + "onAgentRunStart" | "onReplyStart" | "onTypingController" | "onTypingCleanup" + >; markDispatchIdle: () => void; /** Signal that the model run is complete so the typing controller can stop. */ markRunComplete: () => void; @@ -376,6 +381,7 @@ export function createReplyDispatcherWithTyping( ): ReplyDispatcherWithTypingResult { const { typingCallbacks, + onAgentRunStart, onReplyStart, onIdle, onSettled: _onSettled, @@ -398,6 +404,7 @@ export function createReplyDispatcherWithTyping( return { dispatcher, replyOptions: { + onAgentRunStart, onReplyStart: resolvedOnReplyStart, onTypingCleanup: resolvedOnCleanup, onTypingController: (typing) => { diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 47f9d9f1ab17d..bb9c60b655d51 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -66,6 +66,10 @@ export type MessageReceivedHookContext = { conversationId?: string; /** Message ID from the provider */ messageId?: string; + /** Provider message id this activity replies to, when supplied. */ + replyToId?: string; + /** Stable provider actor id when supplied by the channel. */ + senderId?: string; /** Additional provider-specific metadata */ metadata?: Record; }; @@ -93,6 +97,8 @@ export type MessageSentHookContext = { conversationId?: string; /** Message ID returned by the provider */ messageId?: string; + /** Agent run identifier when the channel can bind delivery to a turn. */ + runId?: string; /** Whether this message was sent in a group/channel context */ isGroup?: boolean; /** Group or channel identifier, if applicable */ @@ -105,6 +111,30 @@ export type MessageSentHookEvent = InternalHookEvent & { context: MessageSentHookContext; }; +export type MessageFeedbackHookContext = { + channelId: string; + accountId?: string; + kind: "reaction_added" | "reaction_removed" | "feedback_comment"; + /** Provider-authored timestamp when available; otherwise consumers use event timestamp. */ + occurredAt?: string; + /** Stable logical event id; one provider activity can contain multiple facts. */ + providerEventId: string; + providerActivityId: string; + providerConversationId: string; + providerTargetActivityId: string; + actorId: string; + reaction?: string; + content?: string; + /** Provider fields and user content are untrusted input. */ + untrusted: true; +}; + +export type MessageFeedbackHookEvent = InternalHookEvent & { + type: "message"; + action: "feedback"; + context: MessageFeedbackHookContext; +}; + type MessageEnrichedBodyHookContext = { /** Sender identifier (e.g., phone number, user ID) */ from?: string; diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index e5db5ad1c2487..122008ecf34c9 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -369,6 +369,7 @@ describe("message hook mappers", () => { accountId: "acc-1", conversationId: "demo-chat:chat:456", messageId: "msg-1", + senderId: "sender-1", }); expect(internalMetadata?.senderUsername).toBe("userone"); expect(internalMetadata?.senderE164).toBe("+15551234567"); @@ -556,6 +557,7 @@ describe("message hook mappers", () => { accountId: "acc-1", conversationId: "demo-chat:chat:456", messageId: "out-1", + runId: "run-out-1", isGroup: true, groupId: "demo-chat:chat:456", }); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 01c768fdd5841..400ba5fbc80ba 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -486,11 +486,14 @@ export function toInternalMessageReceivedContext( accountId: canonical.accountId, conversationId: canonical.conversationId, messageId: canonical.messageId, + ...(canonical.replyToId ? { replyToId: canonical.replyToId } : {}), + ...(canonical.senderId ? { senderId: canonical.senderId } : {}), metadata: { to: canonical.to, provider: canonical.provider, surface: canonical.surface, threadId: canonical.threadId, + replyToId: canonical.replyToId, senderId: canonical.senderId, senderName: canonical.senderName, senderUsername: canonical.senderUsername, @@ -566,6 +569,7 @@ export function toInternalMessageSentContext( accountId: canonical.accountId, conversationId: canonical.conversationId, messageId: canonical.messageId, + ...(canonical.runId ? { runId: canonical.runId } : {}), ...(canonical.isGroup != null ? { isGroup: canonical.isGroup } : {}), ...(canonical.groupId ? { groupId: canonical.groupId } : {}), };