Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/a365/hosting/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
getChannelBaggagePairs,
getConversationIdAndItemLinkPairs,
resolveEmbodiedAgentIds,
resolveSubChannel,
} from "./turnContextUtils.js";
export { BaggageMiddleware } from "./baggageMiddleware.js";
export {
Expand Down
4 changes: 2 additions & 2 deletions src/a365/hosting/scopeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -96,7 +96,7 @@ export class ScopeUtils {
} {
return {
name: turnContext?.activity?.channelId,
description: turnContext?.activity?.channelIdSubChannel as string | undefined,
description: resolveSubChannel(turnContext?.activity),
};
}

Expand Down
37 changes: 32 additions & 5 deletions src/a365/hosting/turnContextUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,19 +87,46 @@ 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<string, unknown> | undefined;
if (typeof rawChannelData === "string") {
channelData = JSON.parse(rawChannelData) as Record<string, unknown>;
} else if (rawChannelData && typeof rawChannelData === "object") {
channelData = rawChannelData as Record<string, unknown>;
}
if (channelData && typeof channelData.productContext === "string") {
subChannel = channelData.productContext as string;
Comment on lines +94 to +108
}
} catch {
// 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],
[
OpenTelemetryConstants.CHANNEL_LINK_KEY,
turnContext.activity?.channelIdSubChannel as string | undefined,
],
[OpenTelemetryConstants.CHANNEL_LINK_KEY, subChannel],
];
return normalizePairs(pairs);
}
Expand Down
1 change: 1 addition & 0 deletions src/a365/hosting/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ActivityLike {
text?: string;
channelId?: string;
channelIdSubChannel?: string | unknown;
channelData?: unknown;
serviceUrl?: string;
from?: {
id?: string;
Expand Down
1 change: 1 addition & 0 deletions src/a365/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export {
getChannelBaggagePairs,
getConversationIdAndItemLinkPairs,
resolveEmbodiedAgentIds,
resolveSubChannel,
BaggageMiddleware,
OutputLoggingMiddleware,
A365_PARENT_SPAN_KEY,
Expand Down
27 changes: 27 additions & 0 deletions test/internal/unit/a365/hosting/scopeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
79 changes: 78 additions & 1 deletion test/internal/unit/a365/hosting/turnContextUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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();
});
});
});
Loading