diff --git a/src/a365/hosting/index.ts b/src/a365/hosting/index.ts index 8ce1653..966301f 100644 --- a/src/a365/hosting/index.ts +++ b/src/a365/hosting/index.ts @@ -10,6 +10,7 @@ export { getChannelBaggagePairs, getConversationIdAndItemLinkPairs, resolveEmbodiedAgentIds, + resolveSubChannel, } from "./turnContextUtils.js"; export { BaggageMiddleware } from "./baggageMiddleware.js"; export { diff --git a/src/a365/hosting/scopeUtils.ts b/src/a365/hosting/scopeUtils.ts index f73bf2e..8200247 100644 --- a/src/a365/hosting/scopeUtils.ts +++ b/src/a365/hosting/scopeUtils.ts @@ -9,7 +9,7 @@ import type { SpanKind, TimeInput } from "@opentelemetry/api"; import type { TurnContextLike } from "./types.js"; -import { resolveEmbodiedAgentIds } from "./turnContextUtils.js"; +import { resolveEmbodiedAgentIds, resolveSubChannel } from "./turnContextUtils.js"; import { InvokeAgentScope, InferenceScope, ExecuteToolScope } from "../scopes/index.js"; import type { AgentDetails, @@ -96,7 +96,7 @@ export class ScopeUtils { } { return { name: turnContext?.activity?.channelId, - description: turnContext?.activity?.channelIdSubChannel as string | undefined, + description: resolveSubChannel(turnContext?.activity), }; } diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts index dacbb59..b4f847e 100644 --- a/src/a365/hosting/turnContextUtils.ts +++ b/src/a365/hosting/turnContextUtils.ts @@ -8,7 +8,7 @@ */ import { OpenTelemetryConstants } from "../constants.js"; -import type { TurnContextLike } from "./types.js"; +import type { ActivityLike, TurnContextLike } from "./types.js"; function normalizePairs(pairs: Array<[string, string | undefined]>): Array<[string, string]> { return pairs @@ -87,6 +87,33 @@ export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, st return tenantId ? [[OpenTelemetryConstants.TENANT_ID_KEY, tenantId]] : []; } +/** + * Resolves the subchannel from an activity, falling back to channelData.productContext + * when channelIdSubChannel is not set directly. + */ +export function resolveSubChannel(activity: ActivityLike | undefined): string | undefined { + const rawSubChannel = activity?.channelIdSubChannel; + let subChannel = + typeof rawSubChannel === "string" && rawSubChannel.trim() !== "" ? rawSubChannel : undefined; + if (!subChannel) { + try { + const rawChannelData = activity?.channelData; + let channelData: Record | undefined; + if (typeof rawChannelData === "string") { + channelData = JSON.parse(rawChannelData) as Record; + } else if (rawChannelData && typeof rawChannelData === "object") { + channelData = rawChannelData as Record; + } + if (channelData && typeof channelData.productContext === "string") { + subChannel = channelData.productContext as string; + } + } catch { + // Ignore parse errors – subChannel stays undefined + } + } + return subChannel; +} + /** * Extracts channel baggage pairs from the TurnContext. */ @@ -94,12 +121,12 @@ export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[str if (!turnContext) { return []; } + + const subChannel = resolveSubChannel(turnContext.activity); + const pairs: Array<[string, string | undefined]> = [ [OpenTelemetryConstants.CHANNEL_NAME_KEY, turnContext.activity?.channelId], - [ - OpenTelemetryConstants.CHANNEL_LINK_KEY, - turnContext.activity?.channelIdSubChannel as string | undefined, - ], + [OpenTelemetryConstants.CHANNEL_LINK_KEY, subChannel], ]; return normalizePairs(pairs); } diff --git a/src/a365/hosting/types.ts b/src/a365/hosting/types.ts index ea2ec49..eb4b93f 100644 --- a/src/a365/hosting/types.ts +++ b/src/a365/hosting/types.ts @@ -18,6 +18,7 @@ export interface ActivityLike { text?: string; channelId?: string; channelIdSubChannel?: string | unknown; + channelData?: unknown; serviceUrl?: string; from?: { id?: string; diff --git a/src/a365/index.ts b/src/a365/index.ts index 2650d0a..9bfa560 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -94,6 +94,7 @@ export { getChannelBaggagePairs, getConversationIdAndItemLinkPairs, resolveEmbodiedAgentIds, + resolveSubChannel, BaggageMiddleware, OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, diff --git a/test/internal/unit/a365/hosting/scopeUtils.test.ts b/test/internal/unit/a365/hosting/scopeUtils.test.ts index ab1952f..2be9c4a 100644 --- a/test/internal/unit/a365/hosting/scopeUtils.test.ts +++ b/test/internal/unit/a365/hosting/scopeUtils.test.ts @@ -215,6 +215,33 @@ describe("ScopeUtils", () => { description: undefined, }); }); + + it("should fall back to productContext when channelIdSubChannel is not set", () => { + const ctx = makeCtx({ + activity: { + channelId: "teams", + channelData: { productContext: "from-product-context" }, + }, + }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: "teams", + description: "from-product-context", + }); + }); + + it("should prefer channelIdSubChannel over productContext", () => { + const ctx = makeCtx({ + activity: { + channelId: "teams", + channelIdSubChannel: "direct-subchannel", + channelData: { productContext: "from-product-context" }, + }, + }); + expect(ScopeUtils.deriveChannelObject(ctx)).toEqual({ + name: "teams", + description: "direct-subchannel", + }); + }); }); describe("populateInferenceScopeFromTurnContext", () => { diff --git a/test/internal/unit/a365/hosting/turnContextUtils.test.ts b/test/internal/unit/a365/hosting/turnContextUtils.test.ts index 2b2851a..7490d4c 100644 --- a/test/internal/unit/a365/hosting/turnContextUtils.test.ts +++ b/test/internal/unit/a365/hosting/turnContextUtils.test.ts @@ -12,9 +12,10 @@ import { getChannelBaggagePairs, getConversationIdAndItemLinkPairs, resolveEmbodiedAgentIds, + resolveSubChannel, OpenTelemetryConstants, } from "../../../../../src/a365/index.js"; -import type { TurnContextLike } from "../../../../../src/a365/index.js"; +import type { TurnContextLike, ActivityLike } from "../../../../../src/a365/index.js"; let contextManager: AsyncLocalStorageContextManager; @@ -259,6 +260,42 @@ describe("TurnContextUtils", () => { const pairs = getChannelBaggagePairs(null as unknown as TurnContextLike); expect(pairs).toEqual([]); }); + + it("should extract subChannel from productContext when channelIdSubChannel is not set", () => { + const ctx = makeMockTurnContext(); + ctx.activity.channelIdSubChannel = undefined; + ctx.activity.channelData = { productContext: "from-product-context" }; + const pairs = getChannelBaggagePairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("from-product-context"); + }); + + it("should prefer channelIdSubChannel over productContext", () => { + const ctx = makeMockTurnContext(); + ctx.activity.channelIdSubChannel = "direct-subchannel"; + ctx.activity.channelData = { productContext: "from-product-context" }; + const pairs = getChannelBaggagePairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("direct-subchannel"); + }); + + it("should extract subChannel from JSON string channelData", () => { + const ctx = makeMockTurnContext(); + ctx.activity.channelIdSubChannel = undefined; + ctx.activity.channelData = JSON.stringify({ productContext: "json-string-context" }); + const pairs = getChannelBaggagePairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBe("json-string-context"); + }); + + it("should handle invalid JSON channelData gracefully", () => { + const ctx = makeMockTurnContext(); + ctx.activity.channelIdSubChannel = undefined; + ctx.activity.channelData = "not-valid-json{{{"; + const pairs = getChannelBaggagePairs(ctx); + const obj = Object.fromEntries(pairs); + expect(obj[OpenTelemetryConstants.CHANNEL_LINK_KEY]).toBeUndefined(); + }); }); describe("getConversationIdAndItemLinkPairs", () => { @@ -277,4 +314,44 @@ describe("TurnContextUtils", () => { expect(pairs).toEqual([]); }); }); + + describe("resolveSubChannel", () => { + it("should return channelIdSubChannel when set", () => { + const activity: ActivityLike = { channelIdSubChannel: "general" }; + expect(resolveSubChannel(activity)).toBe("general"); + }); + + it("should fall back to productContext from object channelData", () => { + const activity: ActivityLike = { + channelData: { productContext: "from-product-context" }, + }; + expect(resolveSubChannel(activity)).toBe("from-product-context"); + }); + + it("should fall back to productContext from JSON string channelData", () => { + const activity: ActivityLike = { + channelData: JSON.stringify({ productContext: "json-string-context" }), + }; + expect(resolveSubChannel(activity)).toBe("json-string-context"); + }); + + it("should return undefined for invalid JSON channelData", () => { + const activity: ActivityLike = { + channelData: "not-valid-json{{{", + }; + expect(resolveSubChannel(activity)).toBeUndefined(); + }); + + it("should prefer channelIdSubChannel over productContext", () => { + const activity: ActivityLike = { + channelIdSubChannel: "direct-subchannel", + channelData: { productContext: "from-product-context" }, + }; + expect(resolveSubChannel(activity)).toBe("direct-subchannel"); + }); + + it("should return undefined when activity is undefined", () => { + expect(resolveSubChannel(undefined)).toBeUndefined(); + }); + }); });