mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
fix(providers): copilot-m365-web invocation dropped by substrate + token refresh (#10732)
Merged via merge-train (release/v3.8.50, batch1 2026-08-20) — static gates (typecheck/file-size/complexity/cognitive/changelog) green on the combined tree; test:unit reds observed in the boarded run were verified pre-existing on the pure release tip (unrelated flake), not caused by this PR. Thanks for the contribution!
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(providers):** copilot-m365-web chat turns no longer surface as `(empty response)` — the type:4 invocation is aligned with the 2026-08 wire shape and now carries its type:1 Metrics follow-up in the same socket write, and the access token pre-flight-refreshes from a stored refresh_token instead of requiring a DevTools re-capture every ~75 minutes ([#10732](https://github.com/diegosouzapw/OmniRoute/pull/10732) — thanks @acc0mplish)
|
||||
@@ -91,7 +91,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `chatgpt-web-codex` | `cgpt-codex` | ChatGPT Web (Codex) | Web cookie | [link](https://chatgpt.com) | Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile. | native |
|
||||
| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none |
|
||||
| `conol-web` | `cnl` | Conol (Unofficial/Experimental) | Web cookie | [link](https://conol.ai) | Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required. | — |
|
||||
| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | — |
|
||||
| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry. | — |
|
||||
| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste the access_token from an authenticated copilot.microsoft.com request (DevTools → Network → Authorization), or export a HAR while logged in | — |
|
||||
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | emulated |
|
||||
| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | — |
|
||||
|
||||
@@ -160,7 +160,16 @@ export function resolveConnectionParams(
|
||||
const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord;
|
||||
const parsedApiKey =
|
||||
typeof credentials?.apiKey === "string" ? parsePastedCredential(credentials.apiKey) : {};
|
||||
// A JWT in credentials.accessToken (3 dot-separated parts — the individual-tier
|
||||
// token is an opaque JWE with 5) is the freshest copy: the executor refreshes it
|
||||
// in place before resolving params, and the framework mutates it after a refresh.
|
||||
const credentialsJwt =
|
||||
typeof credentials?.accessToken === "string" &&
|
||||
credentials.accessToken.split(".").length === 3
|
||||
? credentials.accessToken
|
||||
: "";
|
||||
const accessToken =
|
||||
credentialsJwt ||
|
||||
parsedApiKey.accessToken ||
|
||||
(typeof credentials?.apiKey === "string" &&
|
||||
credentials.apiKey &&
|
||||
@@ -254,6 +263,135 @@ export function redactWsUrl(wsUrl: string): string {
|
||||
return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED");
|
||||
}
|
||||
|
||||
// ── OAuth refresh support (#10718 — client ids observed in the browser token
|
||||
// and M365-Copilot2API) ────────────────────────────────────────────────────
|
||||
//
|
||||
// The browser-issued access_token lives ~75 minutes. These helpers redeem a
|
||||
// stored refresh_token at the Microsoft identity platform (same public client
|
||||
// the m365.cloud.microsoft web app uses) so the connection self-heals instead
|
||||
// of requiring a fresh DevTools capture after every expiry.
|
||||
|
||||
/** Public client id observed in both the browser token and M365-Copilot2API. */
|
||||
export const M365_OAUTH_CLIENT_ID = "c0ab8ce9-e9a0-42e7-b064-33d422df41f1";
|
||||
|
||||
export const M365_OAUTH_SCOPE =
|
||||
"openid profile offline_access https://substrate.office.com/sydney/M365Chat.Read " +
|
||||
"https://substrate.office.com/sydney/sydney.readwrite";
|
||||
|
||||
/** Refresh lead time — refresh when the current token has less than this left. */
|
||||
export const M365_REFRESH_LEAD_MS = 5 * 60 * 1000;
|
||||
|
||||
type MinimalLog = {
|
||||
info?: (tag: string, message: string) => void;
|
||||
warn?: (tag: string, message: string) => void;
|
||||
};
|
||||
|
||||
/** Decode a JWT payload WITHOUT verification — exp/tid are routing hints, never authz. */
|
||||
export function decodeJwtClaims(
|
||||
token: string
|
||||
): { exp?: number; tid?: string; oid?: string } | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
||||
return payload && typeof payload === "object" ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the token is unreadable, already expired, or inside the refresh lead window. */
|
||||
export function tokenNeedsRefresh(token: string, leadMs = M365_REFRESH_LEAD_MS): boolean {
|
||||
const claims = decodeJwtClaims(token);
|
||||
if (!claims?.exp) return true;
|
||||
return claims.exp * 1000 <= Date.now() + leadMs;
|
||||
}
|
||||
|
||||
/** The freshest readable access token for a connection (JWT column → apiKey → psd). */
|
||||
export function currentM365AccessToken(
|
||||
credentials: ProviderCredentials | undefined
|
||||
): string {
|
||||
if (
|
||||
typeof credentials?.accessToken === "string" &&
|
||||
credentials.accessToken.split(".").length === 3
|
||||
) {
|
||||
return credentials.accessToken;
|
||||
}
|
||||
if (typeof credentials?.apiKey === "string") {
|
||||
const parsed = parsePastedCredential(credentials.apiKey);
|
||||
if (parsed.accessToken && parsed.accessToken.split(".").length === 3) return parsed.accessToken;
|
||||
// Opaque (JWE) individual-tier token — still a usable credential, just not refreshable.
|
||||
return parsed.accessToken || "";
|
||||
}
|
||||
const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord;
|
||||
if (typeof psd.accessToken === "string") return psd.accessToken;
|
||||
if (typeof psd.access_token === "string") return psd.access_token;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** The chathub path (`<user-oid>@<tenant-id>`) from wherever it is stored. */
|
||||
export function currentM365ChathubPath(credentials: ProviderCredentials | undefined): string {
|
||||
const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord;
|
||||
return (
|
||||
(typeof credentials?.apiKey === "string"
|
||||
? parsePastedCredential(credentials.apiKey).chathubPath
|
||||
: "") ||
|
||||
(typeof psd.chathubPath === "string" && psd.chathubPath) ||
|
||||
(typeof psd.userTenant === "string" && psd.userTenant) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export interface M365RefreshResult {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem the refresh_token (public client — no secret). MS may rotate the
|
||||
* refresh_token; callers MUST persist the returned one when present or the
|
||||
* token family dies after the first refresh.
|
||||
*/
|
||||
export async function refreshM365AccessToken(
|
||||
refreshToken: string,
|
||||
tid: string,
|
||||
log?: MinimalLog
|
||||
): Promise<M365RefreshResult | { error: string }> {
|
||||
const endpoint = `https://login.microsoftonline.com/${tid || "common"}/oauth2/v2.0/token`;
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: M365_OAUTH_CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
scope: M365_OAUTH_SCOPE,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok || typeof data.access_token !== "string") {
|
||||
const error = typeof data.error === "string" ? data.error : `HTTP ${res.status}`;
|
||||
log?.warn?.("M365_TOKEN", `refresh_token grant failed: ${error}`);
|
||||
return { error };
|
||||
}
|
||||
log?.info?.("M365_TOKEN", "access token refreshed via refresh_token grant");
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
|
||||
expiresIn: typeof data.expires_in === "number" ? data.expires_in : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
log?.warn?.("M365_TOKEN", `refresh request failed: ${error}`);
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten OpenAI messages into a single prompt (system instructions prepended). */
|
||||
export function buildPrompt(body: JsonRecord | undefined): string {
|
||||
const messages = (body?.messages as Array<JsonRecord>) || [];
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* Protocol (from @skyzea1's #4042 capture):
|
||||
* - JSON messages terminated with the SignalR record separator `\x1e`.
|
||||
* - Handshake: → {"protocol":"json","version":1} ← {} → {"type":6}
|
||||
* - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... }
|
||||
* - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... },
|
||||
* immediately followed by a type:1 target:"Metrics" frame in the SAME socket
|
||||
* write (#10718 — an invocation without its Metrics pair is silently dropped).
|
||||
* - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text,
|
||||
* accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion.
|
||||
*/
|
||||
@@ -25,19 +27,18 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
|
||||
/** SignalR keepalive ping frame. */
|
||||
export const KEEPALIVE_PING = { type: 6 } as const;
|
||||
|
||||
/** Allowed message types observed in the individual M365 send frame. */
|
||||
/**
|
||||
* Allowed message types observed in the 2026-08 recapture of the working
|
||||
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
|
||||
* seen on the wire — the stale shape gets closed immediately after the type:4.
|
||||
*/
|
||||
export const ALLOWED_MESSAGE_TYPES = [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"InternalLoaderMessage",
|
||||
"Progress",
|
||||
"GeneratedCode",
|
||||
"RenderCardRequest",
|
||||
"AdsQuery",
|
||||
"SemanticSerp",
|
||||
"GenerateContentQuery",
|
||||
"EndOfRequest",
|
||||
"InternalLoaderMessage",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -74,22 +75,20 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
|
||||
"SwitchRespondingEndpoint",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Individual / EDU option sets from the 2026-08 recapture (#10718) — 14 entries.
|
||||
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
|
||||
* …) is no longer observed on the wire and belongs to the shape the substrate
|
||||
* now drops silently.
|
||||
*/
|
||||
export const M365_DEFAULT_OPTION_SETS = [
|
||||
"search_result_progress_messages_with_search_queries",
|
||||
"update_textdoc_response_after_streaming",
|
||||
"deepleo_networking_timeout_10minutes_canmore",
|
||||
"cwc_flux_image",
|
||||
"cwc_code_interpreter",
|
||||
"cwc_code_interpreter_amsfix",
|
||||
"enable_msa_user",
|
||||
"cwcgptv",
|
||||
"cwcfluxgptv",
|
||||
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
|
||||
"gptvnorm2048",
|
||||
"pdnascan",
|
||||
"cwc_code_interpreter_citation_fix",
|
||||
"code_interpreter_interactive_charts",
|
||||
"cwc_code_interpreter_interactive_charts_inline_image",
|
||||
"code_interpreter_matplotlib_patching",
|
||||
"cwc_fileupload_odb",
|
||||
"update_memory_plugin",
|
||||
"add_custom_instructions",
|
||||
@@ -97,9 +96,6 @@ export const M365_DEFAULT_OPTION_SETS = [
|
||||
"flux_v3_progress_messages",
|
||||
"enable_batch_token_processing",
|
||||
"enable_gg_gpt",
|
||||
"flux_v3_image_gen_enable_non_watermarked_storage",
|
||||
"flux_v3_image_gen_enable_story",
|
||||
"rich_responses",
|
||||
] as const;
|
||||
|
||||
/** Append the record separator to a JSON-serializable frame. */
|
||||
@@ -117,6 +113,32 @@ export function keepaliveFrame(): string {
|
||||
return encodeFrame(KEEPALIVE_PING);
|
||||
}
|
||||
|
||||
/**
|
||||
* #10718 — the browser follows the type:4 chat invocation with this type:1
|
||||
* target:"Metrics" frame in the SAME socket write. Sending the invocation alone
|
||||
* gets it silently ignored (no update frames at all), so the executor must
|
||||
* concatenate `metricsFrame()` onto the invocation payload.
|
||||
*/
|
||||
export const CHAT_METRICS_FRAME = {
|
||||
arguments: [
|
||||
{
|
||||
Timestamps: {
|
||||
ConnectionEstablished: "",
|
||||
ConnectionStart: "",
|
||||
UserInputStart: "",
|
||||
UserInputSubmit: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
target: "Metrics",
|
||||
type: 1,
|
||||
} as const;
|
||||
|
||||
/** Serialized Metrics follow-up frame (see {@link CHAT_METRICS_FRAME}). */
|
||||
export function metricsFrame(): string {
|
||||
return encodeFrame(CHAT_METRICS_FRAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a raw socket buffer into complete `\x1e`-terminated frames, returning any
|
||||
* trailing partial frame as `rest` so it can be prepended to the next chunk.
|
||||
@@ -155,22 +177,37 @@ export function handshakeError(frame: Record<string, unknown> | null): string |
|
||||
|
||||
export interface ChatInvocationOptions {
|
||||
text: string;
|
||||
/** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */
|
||||
/** Per-invocation trace id (GUID). */
|
||||
traceId: string;
|
||||
/** Per-session id (GUID). */
|
||||
/** Client correlation id; defaults to {@link ChatInvocationOptions.traceId}. */
|
||||
clientCorrelationId?: string;
|
||||
/** Per-session id (GUID, == the WS URL X-SessionId query). */
|
||||
sessionId: string;
|
||||
/** Per-request id (== the WS URL chatsessionid/clientrequestid query). */
|
||||
requestId: string;
|
||||
/**
|
||||
* Conversation id — MUST match the ConversationId query of the WS URL the
|
||||
* invocation rides on (#10718: the server cross-checks the two).
|
||||
*/
|
||||
conversationId: string;
|
||||
/** BCP-47 locale echoed in message.locale; defaults to "en-us". */
|
||||
locale?: string;
|
||||
/** IANA time zone for message.locationInfo; defaults to "UTC". */
|
||||
timeZone?: string;
|
||||
/** Hour offset for message.locationInfo; defaults to 0. */
|
||||
timeZoneOffset?: number;
|
||||
/** Whether this is the first turn of the conversation. */
|
||||
isStartOfSession?: boolean;
|
||||
/** Tier-specific option flags; left empty by default (tuned during live validation). */
|
||||
/** Tier-specific option flags; defaults to {@link M365_DEFAULT_OPTION_SETS}. */
|
||||
optionsSets?: string[];
|
||||
tone?: string;
|
||||
/** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */
|
||||
allowedMessageTypes?: readonly string[];
|
||||
/**
|
||||
* Tier-specific disconnect behavior sent in every type:4 chat invocation. The work
|
||||
* Surface rejects any value other than exactly "continue" (#8971). Defaults to ""
|
||||
* for individual/consumer/EDU tiers; {@link resolveChatInvocationOverrides} returns
|
||||
* "continue" for the enterprise tier.
|
||||
* Tier-specific disconnect behavior sent in the type:4 chat invocation. The work
|
||||
* surface rejects any value other than exactly "continue" (#8971), so the
|
||||
* enterprise tier sends it; the 2026-08 recapture shows the individual/EDU
|
||||
* surface omits the key entirely, so it is left out unless set (#10718).
|
||||
*/
|
||||
disconnectBehavior?: string;
|
||||
}
|
||||
@@ -185,7 +222,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
optionsSets: string[];
|
||||
tone: string;
|
||||
allowedMessageTypes: readonly string[];
|
||||
disconnectBehavior: string;
|
||||
disconnectBehavior: string | undefined;
|
||||
} {
|
||||
if (tier === "enterprise") {
|
||||
return {
|
||||
@@ -197,9 +234,12 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
}
|
||||
return {
|
||||
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
||||
tone: "",
|
||||
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
|
||||
// individual/EDU surface; the old "" default is part of the dropped shape.
|
||||
tone: "magic",
|
||||
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
|
||||
disconnectBehavior: "",
|
||||
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
|
||||
disconnectBehavior: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,7 +247,7 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
* BizChat exposes several models selected by the `tone` field of the `type:4` chat
|
||||
* invocation (#7872, values confirmed against a real enterprise tenant in #7850). Each
|
||||
* tone-selected variant is registered as its own model id; the bare `copilot-m365` id is
|
||||
* intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `""`
|
||||
* intentionally absent here so it keeps the tier default tone (`Magic` on enterprise, `magic`
|
||||
* otherwise) resolved by {@link resolveChatInvocationOverrides}.
|
||||
*/
|
||||
export const M365_MODEL_TONE_MAP: Readonly<Record<string, string>> = {
|
||||
@@ -228,7 +268,14 @@ export function resolveToneForModel(model: string | undefined): string | undefin
|
||||
|
||||
/**
|
||||
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
|
||||
* Mirrors the argument shape captured on the individual M365 path in #4042.
|
||||
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
|
||||
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
|
||||
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
|
||||
* matching the WS URL query, a rich `message` object, and no
|
||||
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
|
||||
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
|
||||
* observed on the wire, and the stale shape gets closed immediately after the
|
||||
* invocation.
|
||||
*/
|
||||
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
|
||||
return {
|
||||
@@ -237,33 +284,48 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
invocationId: "0",
|
||||
arguments: [
|
||||
{
|
||||
source: "officeweb",
|
||||
clientCorrelationId: opts.traceId,
|
||||
sessionId: opts.sessionId,
|
||||
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
|
||||
streamingMode: "ConciseWithPadding",
|
||||
spokenTextMode: "None",
|
||||
options: {},
|
||||
extraExtensionParameters: {},
|
||||
allowedMessageTypes: opts.allowedMessageTypes
|
||||
? [...opts.allowedMessageTypes]
|
||||
: [...ALLOWED_MESSAGE_TYPES],
|
||||
sliceIds: [],
|
||||
threadLevelGptId: {},
|
||||
traceId: opts.traceId,
|
||||
isStartOfSession: opts.isStartOfSession ?? true,
|
||||
clientInfo: {},
|
||||
message: {
|
||||
author: "user",
|
||||
inputMethod: "Keyboard",
|
||||
text: opts.text,
|
||||
messageType: "Chat",
|
||||
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
|
||||
clientInfo: {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
},
|
||||
conversationId: opts.conversationId,
|
||||
isStartOfSession: opts.isStartOfSession ?? true,
|
||||
message: {
|
||||
adaptiveCards: [],
|
||||
attachments: null,
|
||||
author: "user",
|
||||
clientPreferences: {},
|
||||
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
|
||||
experienceType: "Default",
|
||||
inputMethod: "Keyboard",
|
||||
locale: opts.locale ?? "en-us",
|
||||
locationInfo: {
|
||||
timeZone: opts.timeZone ?? "UTC",
|
||||
timeZoneOffset: opts.timeZoneOffset ?? 0,
|
||||
},
|
||||
messageType: "Chat",
|
||||
requestId: opts.requestId,
|
||||
text: opts.text,
|
||||
},
|
||||
options: {},
|
||||
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
|
||||
plugins: [],
|
||||
isSbsSupported: false,
|
||||
tone: opts.tone ?? "",
|
||||
renderReferencesBehindEOS: true,
|
||||
disconnectBehavior: opts.disconnectBehavior ?? "",
|
||||
productThreadType: "Office",
|
||||
sessionId: opts.sessionId,
|
||||
sliceIds: [],
|
||||
source: "officeweb",
|
||||
streamingMode: "ConciseWithPadding",
|
||||
threadLevelGptId: {},
|
||||
tone: opts.tone ?? "magic",
|
||||
toolChoice: null,
|
||||
traceId: opts.traceId,
|
||||
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
|
||||
// omits the key, so only include it when actually set (#10718).
|
||||
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -5,8 +5,13 @@ import { BaseExecutor, type ExecuteInput, type ExecutorLog } from "./base.ts";
|
||||
import {
|
||||
buildPrompt,
|
||||
buildWsUrl,
|
||||
currentM365AccessToken,
|
||||
currentM365ChathubPath,
|
||||
decodeJwtClaims,
|
||||
redactWsUrl,
|
||||
refreshM365AccessToken,
|
||||
resolveConnectionParams,
|
||||
tokenNeedsRefresh,
|
||||
} from "./copilot-m365-connection.ts";
|
||||
import {
|
||||
accumulateBotContent,
|
||||
@@ -17,7 +22,7 @@ import {
|
||||
handshakeFrame,
|
||||
isCompletionFrame,
|
||||
isUpdateFrame,
|
||||
keepaliveFrame,
|
||||
metricsFrame,
|
||||
parseFrame,
|
||||
resolveChatInvocationOverrides,
|
||||
resolveToneForModel,
|
||||
@@ -152,8 +157,17 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
|
||||
try {
|
||||
const wsUrlParts = new URL(input.wsUrl);
|
||||
const traceId = wsUrlParts.searchParams.get("clientrequestid") ?? crypto.randomUUID().replace(/-/g, "");
|
||||
// #10718 — the invocation must echo the ids riding in the WS URL query
|
||||
// (conversationId is cross-checked server-side). traceId is a fresh GUID
|
||||
// per turn, as in the browser capture.
|
||||
const requestId =
|
||||
wsUrlParts.searchParams.get("chatsessionid") ??
|
||||
wsUrlParts.searchParams.get("clientrequestid") ??
|
||||
crypto.randomUUID();
|
||||
const sessionId = wsUrlParts.searchParams.get("X-SessionId") ?? crypto.randomUUID();
|
||||
const conversationId =
|
||||
wsUrlParts.searchParams.get("ConversationId") ?? crypto.randomUUID();
|
||||
const traceId = crypto.randomUUID();
|
||||
|
||||
log?.debug?.("M365_WS", `connecting → ${redactWsUrl(input.wsUrl)}`);
|
||||
|
||||
@@ -166,23 +180,26 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
});
|
||||
|
||||
const sendChat = () => {
|
||||
ws?.send(keepaliveFrame());
|
||||
const overrides = resolveChatInvocationOverrides(input.tier);
|
||||
// Model-driven tone (#7872) wins over the tier default; a bare/unknown id
|
||||
// keeps the tier tone resolved above.
|
||||
const tone = resolveToneForModel(input.model) ?? overrides.tone;
|
||||
ws?.send(
|
||||
encodeFrame(
|
||||
buildChatInvocation({
|
||||
text: input.prompt,
|
||||
traceId,
|
||||
sessionId,
|
||||
isStartOfSession: true,
|
||||
...overrides,
|
||||
tone,
|
||||
})
|
||||
)
|
||||
const invocationFrame = encodeFrame(
|
||||
buildChatInvocation({
|
||||
text: input.prompt,
|
||||
traceId,
|
||||
sessionId,
|
||||
requestId,
|
||||
conversationId,
|
||||
isStartOfSession: true,
|
||||
...overrides,
|
||||
tone,
|
||||
})
|
||||
);
|
||||
// #10718 — the invocation and its type:1 Metrics follow-up must land
|
||||
// in ONE socket write, exactly as the browser sends them; a bare
|
||||
// invocation (or one preceded by a type:6 ping) is silently dropped.
|
||||
ws?.send(invocationFrame + metricsFrame());
|
||||
};
|
||||
|
||||
ws.on("open", () => {
|
||||
@@ -273,6 +290,61 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #10718 — proactively refresh the M365 access token before opening the WS.
|
||||
* A WS-handshake 401 surfaces as an error event INSIDE the SSE stream (the HTTP
|
||||
* response is already 200 by then), so chatCore's generic 401→refresh→retry
|
||||
* orchestration never triggers — the refresh has to happen here, pre-flight.
|
||||
* No-ops for legacy connections without a stored refresh_token.
|
||||
*/
|
||||
private async ensureFreshCredentials(
|
||||
credentials: ExecuteInput["credentials"],
|
||||
onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"],
|
||||
log: ExecutorLog | null
|
||||
): Promise<void> {
|
||||
const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord;
|
||||
const refreshToken =
|
||||
credentials.refreshToken || (typeof psd.refreshToken === "string" ? psd.refreshToken : "");
|
||||
if (!refreshToken) return;
|
||||
|
||||
const current = currentM365AccessToken(credentials);
|
||||
if (current && !tokenNeedsRefresh(current)) return;
|
||||
|
||||
const tid =
|
||||
decodeJwtClaims(current)?.tid || (typeof psd.tid === "string" ? psd.tid : "") || "";
|
||||
const result = await refreshM365AccessToken(refreshToken, tid, log ?? undefined);
|
||||
if ("error" in result) {
|
||||
// Fall through with the existing token — the WS layer will surface the failure.
|
||||
return;
|
||||
}
|
||||
|
||||
const rotated = result.refreshToken || refreshToken;
|
||||
const chathubPath = currentM365ChathubPath(credentials);
|
||||
const next = {
|
||||
...credentials,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: rotated,
|
||||
// Keep the pasted-format apiKey self-consistent so every resolution path
|
||||
// (fresh column, stale column, dashboard re-read) sees the same token.
|
||||
...(chathubPath
|
||||
? { apiKey: `access_token=${result.accessToken}; chathubPath=${chathubPath}` }
|
||||
: {}),
|
||||
...(result.expiresIn
|
||||
? { expiresAt: new Date(Date.now() + result.expiresIn * 1000).toISOString() }
|
||||
: {}),
|
||||
};
|
||||
Object.assign(credentials, next);
|
||||
try {
|
||||
await onCredentialsRefreshed?.(next);
|
||||
} catch (err) {
|
||||
// #7676 pattern: a persistence failure must never fail the user-facing response.
|
||||
log?.warn?.(
|
||||
"M365_TOKEN",
|
||||
`persisting refreshed token failed (${err instanceof Error ? err.message : String(err)}) — will re-refresh next request`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<{
|
||||
response: Response;
|
||||
url: string;
|
||||
@@ -293,6 +365,12 @@ export class CopilotM365WebExecutor extends BaseExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
await this.ensureFreshCredentials(
|
||||
input.credentials,
|
||||
input.onCredentialsRefreshed,
|
||||
input.log ?? null
|
||||
);
|
||||
|
||||
const connectionParams = resolveConnectionParams(input.credentials);
|
||||
if ("error" in connectionParams) {
|
||||
return {
|
||||
|
||||
@@ -152,7 +152,7 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
textIcon: "M365",
|
||||
website: "https://m365.cloud.microsoft/chat",
|
||||
authHint:
|
||||
"Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration.",
|
||||
"Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry.",
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "webCookie",
|
||||
},
|
||||
|
||||
@@ -29,12 +29,24 @@ class MockM365WebSocket {
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(String(data));
|
||||
const parsed = JSON.parse(String(data).replace(/\x1e$/, ""));
|
||||
if (parsed.protocol === "json") {
|
||||
// #10718 — a single socket write may carry multiple \x1e-terminated frames
|
||||
// (the chat invocation and its Metrics follow-up ride together).
|
||||
const parsedFrames = String(data)
|
||||
.split("\x1e")
|
||||
.filter((f) => f.length > 0)
|
||||
.map((f) => {
|
||||
try {
|
||||
return JSON.parse(f);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0];
|
||||
if (parsed?.protocol === "json") {
|
||||
queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({}))));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === 4 && parsed.target === "chat") {
|
||||
if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) {
|
||||
queueMicrotask(() => {
|
||||
this.emit(
|
||||
"message",
|
||||
@@ -83,7 +95,7 @@ async function sendChatInvocation(tier: string | undefined) {
|
||||
assert.equal(MockM365WebSocket.instances.length, 1);
|
||||
const sentFrames = MockM365WebSocket.instances[0].sent;
|
||||
const chatFrameRaw = sentFrames
|
||||
.map((f) => f.replace(/\x1e$/, ""))
|
||||
.flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0))
|
||||
.map((f) => {
|
||||
try {
|
||||
return JSON.parse(f);
|
||||
@@ -132,31 +144,30 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () =
|
||||
assert.equal(invocationArgs.tone, "Magic");
|
||||
});
|
||||
|
||||
test("#7870: individual (no tier) chat invocation payload stays byte-identical to today", async () => {
|
||||
test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => {
|
||||
const invocationArgs = await sendChatInvocation(undefined);
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "");
|
||||
// The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from
|
||||
// the wire — the stale set was part of the silently-dropped shape.
|
||||
assert.ok(!optionsSets.includes("enable_msa_user"));
|
||||
assert.ok(optionsSets.includes("enable_gg_gpt"));
|
||||
// The browser sends tone:"magic" (lowercase) on the individual/EDU surface.
|
||||
assert.equal(invocationArgs.tone, "magic");
|
||||
assert.deepEqual(invocationArgs.allowedMessageTypes, [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"InternalLoaderMessage",
|
||||
"Progress",
|
||||
"GeneratedCode",
|
||||
"RenderCardRequest",
|
||||
"AdsQuery",
|
||||
"SemanticSerp",
|
||||
"GenerateContentQuery",
|
||||
"EndOfRequest",
|
||||
"InternalLoaderMessage",
|
||||
]);
|
||||
});
|
||||
|
||||
test("#7870: EDU-tier chat invocation payload stays byte-identical to today (unaffected by enterprise change)", async () => {
|
||||
test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => {
|
||||
const invocationArgs = await sendChatInvocation("edu");
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "");
|
||||
assert.ok(!optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "magic");
|
||||
});
|
||||
|
||||
test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => {
|
||||
@@ -168,11 +179,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin
|
||||
);
|
||||
});
|
||||
|
||||
test("#8971: individual (no tier) chat invocation disconnectBehavior remains empty (byte-identical to #4042)", async () => {
|
||||
test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => {
|
||||
const invocationArgs = await sendChatInvocation(undefined);
|
||||
assert.equal(
|
||||
invocationArgs.disconnectBehavior,
|
||||
"",
|
||||
`individual-tier invocation must carry disconnectBehavior=""; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
|
||||
undefined,
|
||||
`individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
|
||||
);
|
||||
});
|
||||
|
||||
205
tests/unit/copilot-m365-invocation-refresh-10718.test.ts
Normal file
205
tests/unit/copilot-m365-invocation-refresh-10718.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #10718 — the substrate started dropping the old type:4 chat invocation shape
|
||||
// (immediate bare type:3 close, "(empty response)" on every request). A fresh
|
||||
// TLS-MITM capture of a working m365.cloud.microsoft/chat round-trip (2026-08)
|
||||
// showed a materially different argument shape AND a type:1 target:"Metrics"
|
||||
// frame written in the SAME socket write right after the invocation — an
|
||||
// invocation without its Metrics pair is silently ignored.
|
||||
//
|
||||
// These tests pin the recaptured wire shape and the refresh_token pre-flight
|
||||
// (the browser-issued access_token lives ~75 min with no refresh path before
|
||||
// this). Live round-trip on a real EDU (A3/Starter) tenant is the separate
|
||||
// Rule #18 validation gate.
|
||||
|
||||
import {
|
||||
RECORD_SEPARATOR,
|
||||
metricsFrame,
|
||||
buildChatInvocation,
|
||||
resolveChatInvocationOverrides,
|
||||
M365_DEFAULT_OPTION_SETS,
|
||||
ALLOWED_MESSAGE_TYPES,
|
||||
} from "../../open-sse/executors/copilot-m365-frames.ts";
|
||||
import {
|
||||
decodeJwtClaims,
|
||||
tokenNeedsRefresh,
|
||||
refreshM365AccessToken,
|
||||
M365_OAUTH_CLIENT_ID,
|
||||
M365_REFRESH_LEAD_MS,
|
||||
} from "../../open-sse/executors/copilot-m365-connection.ts";
|
||||
|
||||
// ── Metrics follow-up frame ────────────────────────────────────────────────
|
||||
|
||||
test("#10718: metricsFrame emits the exact bytes observed in the browser capture", () => {
|
||||
assert.equal(
|
||||
metricsFrame(),
|
||||
'{"arguments":[{"Timestamps":{"ConnectionEstablished":"","ConnectionStart":"","UserInputStart":"","UserInputSubmit":""}}],"target":"Metrics","type":1}' +
|
||||
RECORD_SEPARATOR
|
||||
);
|
||||
});
|
||||
|
||||
// ── Recaptured invocation shape ────────────────────────────────────────────
|
||||
|
||||
test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => {
|
||||
const arg = buildChatInvocation({
|
||||
text: "Say OK in one word.",
|
||||
traceId: "11111111-1111-1111-1111-111111111111",
|
||||
sessionId: "22222222-2222-2222-2222-222222222222",
|
||||
requestId: "33333333-3333-3333-3333-333333333333",
|
||||
conversationId: "44444444-4444-4444-4444-444444444444",
|
||||
}).arguments[0] as Record<string, unknown>;
|
||||
|
||||
// Exact key set from the capture — additions AND omissions are both pinned,
|
||||
// because the stale keys are exactly what got the shape dropped.
|
||||
assert.deepEqual(Object.keys(arg).sort(), [
|
||||
"allowedMessageTypes",
|
||||
"clientCorrelationId",
|
||||
"clientInfo",
|
||||
"conversationId",
|
||||
"isStartOfSession",
|
||||
"message",
|
||||
"options",
|
||||
"optionsSets",
|
||||
"plugins",
|
||||
"productThreadType",
|
||||
"sessionId",
|
||||
"sliceIds",
|
||||
"source",
|
||||
"streamingMode",
|
||||
"threadLevelGptId",
|
||||
"tone",
|
||||
"toolChoice",
|
||||
"traceId",
|
||||
]);
|
||||
assert.equal(arg.productThreadType, "Office");
|
||||
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
|
||||
assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444");
|
||||
assert.equal(arg.toolChoice, null);
|
||||
assert.equal(arg.tone, "magic");
|
||||
});
|
||||
|
||||
test("#10718: the message object carries the recaptured rich shape", () => {
|
||||
const arg = buildChatInvocation({
|
||||
text: "Say OK in one word.",
|
||||
traceId: "t",
|
||||
sessionId: "s",
|
||||
requestId: "r",
|
||||
conversationId: "c",
|
||||
}).arguments[0] as Record<string, unknown>;
|
||||
const message = arg.message as Record<string, unknown>;
|
||||
|
||||
assert.deepEqual(Object.keys(message).sort(), [
|
||||
"adaptiveCards",
|
||||
"attachments",
|
||||
"author",
|
||||
"clientPreferences",
|
||||
"entityAnnotationTypes",
|
||||
"experienceType",
|
||||
"inputMethod",
|
||||
"locale",
|
||||
"locationInfo",
|
||||
"messageType",
|
||||
"requestId",
|
||||
"text",
|
||||
]);
|
||||
assert.equal(message.author, "user");
|
||||
assert.equal(message.messageType, "Chat");
|
||||
assert.equal(message.requestId, "r");
|
||||
assert.equal(message.experienceType, "Default");
|
||||
assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]);
|
||||
assert.equal(message.attachments, null);
|
||||
assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 });
|
||||
});
|
||||
|
||||
test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => {
|
||||
const overrides = resolveChatInvocationOverrides(undefined);
|
||||
assert.equal(overrides.optionsSets.length, 14);
|
||||
assert.equal(overrides.allowedMessageTypes.length, 6);
|
||||
assert.equal(overrides.tone, "magic");
|
||||
// The pre-#10718 consumer/MSA flags are gone from the wire.
|
||||
const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[];
|
||||
const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[];
|
||||
for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) {
|
||||
assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`);
|
||||
}
|
||||
for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) {
|
||||
assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`);
|
||||
}
|
||||
// Entries the capture showed and the old lists lacked.
|
||||
assert.ok(optionSets.includes("cwcfluxgptv"));
|
||||
assert.ok(messageTypes.includes("EndOfRequest"));
|
||||
});
|
||||
|
||||
// ── refresh_token helpers ──────────────────────────────────────────────────
|
||||
|
||||
function fakeJwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${b64({ alg: "none" })}.${b64(claims)}.sig`;
|
||||
}
|
||||
|
||||
test("#10718: decodeJwtClaims reads exp/tid without verification; non-JWT returns null", () => {
|
||||
const claims = decodeJwtClaims(fakeJwt({ exp: 123, tid: "tenant-id", oid: "oid" }));
|
||||
assert.equal(claims?.exp, 123);
|
||||
assert.equal(claims?.tid, "tenant-id");
|
||||
assert.equal(decodeJwtClaims("not.a-jwt"), null);
|
||||
assert.equal(decodeJwtClaims("opaque-jwe-token.with.five.parts.here.and-more"), null);
|
||||
});
|
||||
|
||||
test("#10718: tokenNeedsRefresh — unreadable/expired/inside-lead needs refresh, fresh does not", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
assert.equal(tokenNeedsRefresh("opaque"), true);
|
||||
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now - 60 })), true);
|
||||
// Inside the 5-minute lead window.
|
||||
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + M365_REFRESH_LEAD_MS / 1000 - 30 })), true);
|
||||
assert.equal(tokenNeedsRefresh(fakeJwt({ exp: now + 3600 })), false);
|
||||
});
|
||||
|
||||
test("#10718: refreshM365AccessToken redeems the public client grant and returns rotated tokens", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl = "";
|
||||
let capturedBody = "";
|
||||
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
|
||||
capturedUrl = String(url);
|
||||
capturedBody = String(init?.body);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "NEW-ACCESS",
|
||||
refresh_token: "ROTATED-REFRESH",
|
||||
expires_in: 4777,
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const result = await refreshM365AccessToken("OLD-REFRESH", "tenant-id");
|
||||
assert.ok("accessToken" in result);
|
||||
assert.equal(result.accessToken, "NEW-ACCESS");
|
||||
assert.equal(result.refreshToken, "ROTATED-REFRESH");
|
||||
assert.equal(result.expiresIn, 4777);
|
||||
assert.match(capturedUrl, /login\.microsoftonline\.com\/tenant-id\/oauth2\/v2\.0\/token/);
|
||||
assert.match(capturedBody, /grant_type=refresh_token/);
|
||||
assert.match(capturedBody, new RegExp(`client_id=${M365_OAUTH_CLIENT_ID}`));
|
||||
assert.match(capturedBody, /refresh_token=OLD-REFRESH/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("#10718: refreshM365AccessToken surfaces AAD errors and network failures as {error}", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: "invalid_grant", error_description: "AADSTS700082" }), {
|
||||
status: 400,
|
||||
})) as typeof fetch;
|
||||
const aadError = await refreshM365AccessToken("STALE");
|
||||
assert.deepEqual(aadError, { error: "invalid_grant" });
|
||||
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("ENOTFOUND");
|
||||
}) as typeof fetch;
|
||||
const netError = await refreshM365AccessToken("ANY");
|
||||
assert.deepEqual(netError, { error: "ENOTFOUND" });
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
@@ -40,12 +40,24 @@ class MockM365WebSocket {
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(String(data));
|
||||
const parsed = JSON.parse(String(data).replace(/\x1e$/, ""));
|
||||
if (parsed.protocol === "json") {
|
||||
// #10718 — a single socket write may carry multiple \x1e-terminated frames
|
||||
// (the chat invocation and its Metrics follow-up ride together).
|
||||
const parsedFrames = String(data)
|
||||
.split("\x1e")
|
||||
.filter((f) => f.length > 0)
|
||||
.map((f) => {
|
||||
try {
|
||||
return JSON.parse(f);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const parsed = parsedFrames.find((f) => f && f.protocol === "json") ?? parsedFrames[0];
|
||||
if (parsed?.protocol === "json") {
|
||||
queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({}))));
|
||||
return;
|
||||
}
|
||||
if (parsed.type === 4 && parsed.target === "chat") {
|
||||
if (parsedFrames.some((f) => f?.type === 4 && f?.target === "chat")) {
|
||||
queueMicrotask(() => {
|
||||
this.emit(
|
||||
"message",
|
||||
@@ -116,10 +128,18 @@ test("CopilotM365WebExecutor streams OpenAI SSE chunks from accumulated M365 upd
|
||||
assert.doesNotMatch(result.url, /redacted-token/);
|
||||
assert.equal(MockM365WebSocket.instances.length, 1);
|
||||
|
||||
const sent = MockM365WebSocket.instances[0].sent.join("\n");
|
||||
assert.match(sent, /"protocol":"json"/);
|
||||
assert.match(sent, /"type":6/);
|
||||
assert.match(sent, /"target":"chat"/);
|
||||
const sent = MockM365WebSocket.instances[0].sent;
|
||||
const sentFrames = sent.flatMap((f) => f.split("\x1e").filter((frame) => frame.length > 0));
|
||||
assert.ok(sentFrames.some((f) => f.includes('"protocol":"json"')));
|
||||
// #10718 — the chat invocation and its type:1 Metrics follow-up ride in ONE
|
||||
// socket write, and no type:6 keepalive is sent before them.
|
||||
const invocationWrite = sent.find((f) => f.includes('"target":"chat"'));
|
||||
assert.ok(invocationWrite, "expected a chat invocation write");
|
||||
assert.match(invocationWrite, /"target":"Metrics"/);
|
||||
assert.ok(
|
||||
!sentFrames.some((f) => f === '{"type":6}'),
|
||||
"the leading keepalive ping was removed (#10718): it must not precede the invocation"
|
||||
);
|
||||
|
||||
const dataLines = body
|
||||
.split("\n")
|
||||
|
||||
@@ -116,6 +116,8 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te
|
||||
text: "protocol capture test. Reply with one word: pong.",
|
||||
traceId: "trace-id",
|
||||
sessionId: "session-id",
|
||||
requestId: "request-id",
|
||||
conversationId: "conversation-id",
|
||||
isStartOfSession: true,
|
||||
});
|
||||
assert.equal(frame.type, 4);
|
||||
@@ -127,20 +129,30 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te
|
||||
assert.equal(arg.traceId, "trace-id");
|
||||
assert.equal(arg.clientCorrelationId, "trace-id");
|
||||
assert.equal(arg.sessionId, "session-id");
|
||||
assert.equal(arg.conversationId, "conversation-id");
|
||||
assert.equal(arg.productThreadType, "Office");
|
||||
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
|
||||
assert.equal(arg.isStartOfSession, true);
|
||||
assert.ok(Array.isArray(arg.optionsSets));
|
||||
assert.ok((arg.optionsSets as string[]).includes("rich_responses"));
|
||||
assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt"));
|
||||
assert.ok(Array.isArray(arg.allowedMessageTypes));
|
||||
assert.ok((arg.allowedMessageTypes as string[]).includes("Chat"));
|
||||
const message = arg.message as Record<string, unknown>;
|
||||
assert.equal(message.author, "user");
|
||||
assert.equal(message.inputMethod, "Keyboard");
|
||||
assert.equal(message.messageType, "Chat");
|
||||
assert.equal(message.requestId, "request-id");
|
||||
assert.equal(message.text, "protocol capture test. Reply with one word: pong.");
|
||||
});
|
||||
|
||||
test("buildChatInvocation serializes/round-trips through the framing", () => {
|
||||
const frame = buildChatInvocation({ text: "hi", traceId: "t", sessionId: "s" });
|
||||
const frame = buildChatInvocation({
|
||||
text: "hi",
|
||||
traceId: "t",
|
||||
sessionId: "s",
|
||||
requestId: "r",
|
||||
conversationId: "c",
|
||||
});
|
||||
const wire = encodeFrame(frame);
|
||||
assert.ok(wire.endsWith(RECORD_SEPARATOR));
|
||||
const { frames } = splitFrames(wire);
|
||||
|
||||
@@ -30,9 +30,10 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
|
||||
const enterprise = resolveChatInvocationOverrides("enterprise");
|
||||
const individual = resolveChatInvocationOverrides(undefined);
|
||||
|
||||
// enterprise tier default tone is Magic
|
||||
// enterprise tier default tone is Magic; individual/EDU sends "magic" (#10718
|
||||
// recapture — the old "" default was part of the silently-dropped shape)
|
||||
assert.equal(enterprise.tone, "Magic");
|
||||
assert.equal(individual.tone, "");
|
||||
assert.equal(individual.tone, "magic");
|
||||
|
||||
// precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring)
|
||||
const toneFor = (model: string | undefined, tierTone: string) =>
|
||||
@@ -44,7 +45,7 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
|
||||
|
||||
// the bare id keeps whatever the tier resolved
|
||||
assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic");
|
||||
assert.equal(toneFor("copilot-m365", individual.tone), "");
|
||||
assert.equal(toneFor("copilot-m365", individual.tone), "magic");
|
||||
});
|
||||
|
||||
test("registry exposes the bare id (first) plus every tone variant", () => {
|
||||
|
||||
Reference in New Issue
Block a user