From 25e2bd142de8c4604183dc3069e8dc22dee6b9fd Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 11 May 2026 13:40:55 -0700 Subject: [PATCH 1/5] fix: add product context fallback for subchannels Co-Authored-By: Claude Opus 4.6 (1M context) --- src/a365/hosting/turnContextUtils.ts | 26 +++++++++++--- src/a365/hosting/types.ts | 1 + .../a365/hosting/turnContextUtils.test.ts | 36 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts index dacbb59..2cced1d 100644 --- a/src/a365/hosting/turnContextUtils.ts +++ b/src/a365/hosting/turnContextUtils.ts @@ -94,12 +94,30 @@ export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[str if (!turnContext) { return []; } + + let subChannel = turnContext.activity?.channelIdSubChannel as string | undefined; + + // Fallback: extract subChannel from channelData.productContext if not set directly + if (!subChannel) { + try { + const rawChannelData = turnContext.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 + } + } + 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/test/internal/unit/a365/hosting/turnContextUtils.test.ts b/test/internal/unit/a365/hosting/turnContextUtils.test.ts index 2b2851a..c4a123c 100644 --- a/test/internal/unit/a365/hosting/turnContextUtils.test.ts +++ b/test/internal/unit/a365/hosting/turnContextUtils.test.ts @@ -259,6 +259,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", () => { From d5127a19210e26a270c782dda40140675df6cf39 Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 11 May 2026 14:12:18 -0700 Subject: [PATCH 2/5] fix: extract shared resolveSubChannel helper and apply fallback to deriveChannelObject Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 14 ++++++ src/a365/hosting/index.ts | 1 + src/a365/hosting/scopeUtils.ts | 4 +- src/a365/hosting/turnContextUtils.ts | 29 ++++++++----- src/a365/index.ts | 1 + .../unit/a365/hosting/scopeUtils.test.ts | 27 ++++++++++++ .../a365/hosting/turnContextUtils.test.ts | 43 ++++++++++++++++++- 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9f5c4d9 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr:*)", + "Bash(gh issue:*)", + "Bash(gh run:*)", + "Bash(npx prettier:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(gh workflow:*)" + ] + } +} 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 2cced1d..e3fe5d2 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 @@ -88,19 +88,14 @@ export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, st } /** - * Extracts channel baggage pairs from the TurnContext. + * Resolves the subchannel from an activity, falling back to channelData.productContext + * when channelIdSubChannel is not set directly. */ -export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { - if (!turnContext) { - return []; - } - - let subChannel = turnContext.activity?.channelIdSubChannel as string | undefined; - - // Fallback: extract subChannel from channelData.productContext if not set directly +export function resolveSubChannel(activity: ActivityLike | undefined): string | undefined { + let subChannel = activity?.channelIdSubChannel as string | undefined; if (!subChannel) { try { - const rawChannelData = turnContext.activity?.channelData; + const rawChannelData = activity?.channelData; let channelData: Record | undefined; if (typeof rawChannelData === "string") { channelData = JSON.parse(rawChannelData) as Record; @@ -114,6 +109,18 @@ export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[str // Ignore parse errors – subChannel stays undefined } } + return subChannel; +} + +/** + * Extracts channel baggage pairs from the TurnContext. + */ +export function getChannelBaggagePairs(turnContext: TurnContextLike): Array<[string, string]> { + if (!turnContext) { + return []; + } + + const subChannel = resolveSubChannel(turnContext.activity); const pairs: Array<[string, string | undefined]> = [ [OpenTelemetryConstants.CHANNEL_NAME_KEY, turnContext.activity?.channelId], 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 c4a123c..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; @@ -313,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(); + }); + }); }); From 2bfef68684a116506712702027dcaead31dfd71b Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 11 May 2026 16:54:34 -0700 Subject: [PATCH 3/5] chore: remove .claude/settings.local.json from repo Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 9f5c4d9..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh pr:*)", - "Bash(gh issue:*)", - "Bash(gh run:*)", - "Bash(npx prettier:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(gh workflow:*)" - ] - } -} From c43ed67321d598dd609741329ac191ba8b92b331 Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 11 May 2026 16:56:21 -0700 Subject: [PATCH 4/5] fix: address review - validate channelIdSubChannel type before use Co-Authored-By: Claude Opus 4.6 (1M context) --- src/a365/hosting/turnContextUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts index e3fe5d2..7375a80 100644 --- a/src/a365/hosting/turnContextUtils.ts +++ b/src/a365/hosting/turnContextUtils.ts @@ -92,7 +92,8 @@ export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, st * when channelIdSubChannel is not set directly. */ export function resolveSubChannel(activity: ActivityLike | undefined): string | undefined { - let subChannel = activity?.channelIdSubChannel as string | undefined; + const rawSubChannel = activity?.channelIdSubChannel; + let subChannel = typeof rawSubChannel === "string" && rawSubChannel.trim() !== "" ? rawSubChannel : undefined; if (!subChannel) { try { const rawChannelData = activity?.channelData; From 63c7ca73def58e01b9a57d611197fd491f83487b Mon Sep 17 00:00:00 2001 From: Peng Fan Date: Mon, 11 May 2026 17:22:39 -0700 Subject: [PATCH 5/5] style: fix prettier formatting in turnContextUtils.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/a365/hosting/turnContextUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/a365/hosting/turnContextUtils.ts b/src/a365/hosting/turnContextUtils.ts index 7375a80..b4f847e 100644 --- a/src/a365/hosting/turnContextUtils.ts +++ b/src/a365/hosting/turnContextUtils.ts @@ -93,7 +93,8 @@ export function getTenantIdPair(turnContext: TurnContextLike): Array<[string, st */ export function resolveSubChannel(activity: ActivityLike | undefined): string | undefined { const rawSubChannel = activity?.channelIdSubChannel; - let subChannel = typeof rawSubChannel === "string" && rawSubChannel.trim() !== "" ? rawSubChannel : undefined; + let subChannel = + typeof rawSubChannel === "string" && rawSubChannel.trim() !== "" ? rawSubChannel : undefined; if (!subChannel) { try { const rawChannelData = activity?.channelData;