feat(executors): land M365 Copilot pure framing + connection helpers (#4042) (#4696)

Land M365 pure modules ahead of draft #4400
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-22 18:43:49 -03:00
committed by GitHub
parent 4e9d4f4eb7
commit 02482b3e1e
4 changed files with 641 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
/**
* Microsoft 365 Copilot (individual / Substrate BizChat) connection helpers.
*
* Pure URL / credential / prompt builders for the #4042 individual M365 path.
* Kept transport-free (no BaseExecutor import — only a type import) so they can
* be unit-tested without the executor's heavy runtime dependency chain. The
* access_token rides in the WS query string per the protocol, so any logging of
* the URL MUST go through redactWsUrl().
*/
import { randomUUID, randomBytes } from "node:crypto";
import type { ProviderCredentials } from "./base.ts";
type JsonRecord = Record<string, unknown>;
/** Individual-tier defaults observed in @skyzea1's #4042 capture. */
export const M365_INDIVIDUAL_DEFAULTS = {
host: "substrate.office.com",
source: "officeweb",
product: "Office",
agentHost: "Bizchat.FullScreen",
licenseType: "Starter",
agent: "web",
scenario: "OfficeWebPaidConsumerCopilot",
} as const;
export interface M365ConnectionParams {
host: string;
chathubPath: string; // "<user-oid>@<tenant-id>"
accessToken: string;
}
/** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */
export function newChatSessionId(): string {
return randomBytes(16).toString("hex");
}
/**
* Read the pasted credential bits. The individual access_token is opaque (JWE),
* so it is consumed verbatim. The Chathub path (`user@tenant`) is pasted
* alongside it because it is not derivable from the opaque token.
*/
export function resolveConnectionParams(
credentials: ProviderCredentials | undefined
): M365ConnectionParams | { error: string } {
const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord;
const accessToken =
(typeof credentials?.apiKey === "string" && credentials.apiKey) ||
(typeof psd.accessToken === "string" && psd.accessToken) ||
(typeof psd.access_token === "string" && psd.access_token) ||
"";
if (!accessToken) {
return { error: "Missing M365 Copilot access_token. Paste it as the provider credential." };
}
const chathubPath =
(typeof psd.chathubPath === "string" && psd.chathubPath) ||
(typeof psd.userTenant === "string" && psd.userTenant) ||
"";
if (!chathubPath || !chathubPath.includes("@")) {
return {
error:
"Missing M365 Chathub path. Paste the '<user-oid>@<tenant-id>' segment from the WebSocket URL.",
};
}
const host = (typeof psd.host === "string" && psd.host) || M365_INDIVIDUAL_DEFAULTS.host;
return { host, chathubPath, accessToken };
}
/**
* Build the BizChat WebSocket URL. The access_token rides in the query string
* (per the protocol), so callers must never log the returned URL verbatim — use
* redactWsUrl() for any logging.
*/
export function buildWsUrl(params: M365ConnectionParams): string {
const sessionKey = newChatSessionId();
const query = new URLSearchParams({
chatsessionid: sessionKey,
XRoutingParameterSessionKey: sessionKey,
clientrequestid: sessionKey,
"X-SessionId": randomUUID(),
ConversationId: randomUUID(),
access_token: params.accessToken,
source: M365_INDIVIDUAL_DEFAULTS.source,
product: M365_INDIVIDUAL_DEFAULTS.product,
agentHost: M365_INDIVIDUAL_DEFAULTS.agentHost,
licenseType: M365_INDIVIDUAL_DEFAULTS.licenseType,
isEdu: "false",
agent: M365_INDIVIDUAL_DEFAULTS.agent,
scenario: M365_INDIVIDUAL_DEFAULTS.scenario,
});
return `wss://${params.host}/m365Copilot/Chathub/${params.chathubPath}?${query.toString()}`;
}
/** Strip the access_token from a WS URL so it is safe to log. */
export function redactWsUrl(wsUrl: string): string {
return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED");
}
/** Flatten OpenAI messages into a single prompt (system instructions prepended). */
export function buildPrompt(body: JsonRecord | undefined): string {
const messages = (body?.messages as Array<JsonRecord>) || [];
const systemMsgs = messages.filter((m) => m.role === "system");
const userMsg = messages.filter((m) => m.role === "user").pop();
const userText =
typeof userMsg?.content === "string" ? userMsg.content : JSON.stringify(userMsg?.content ?? "");
let prompt = "";
if (systemMsgs.length > 0) {
const sysText = systemMsgs
.map((m) => (typeof m.content === "string" ? m.content : ""))
.filter(Boolean)
.join("\n");
if (sysText) prompt += `[System Instructions]\n${sysText}\n\n`;
}
prompt += userText;
return prompt;
}

View File

@@ -0,0 +1,201 @@
/**
* Microsoft 365 Copilot (BizChat / Substrate) SignalR-over-WebSocket framing.
*
* Pure, transport-free helpers that translate between the OpenAI chat shape and
* the Substrate BizChat SignalR JSON protocol observed on the individual M365
* path (`m365.cloud.microsoft/chat` → `wss://substrate.office.com/m365Copilot/
* Chathub/...`). Keeping these pure lets us unit-test the wire format against the
* real frame captures contributed in #4042 without opening a live socket — the
* live round-trip is the separate Rule #18 validation gate for the executor.
*
* 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, ... }
* - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text,
* accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion.
*/
/** SignalR record separator (0x1e) terminating every JSON frame. */
export const RECORD_SEPARATOR = String.fromCharCode(0x1e);
/** SignalR handshake request — the first frame the client must send. */
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. */
export const ALLOWED_MESSAGE_TYPES = [
"Chat",
"Suggestion",
"InternalSearchQuery",
"Disengaged",
"InternalLoaderMessage",
"Progress",
"GeneratedCode",
"RenderCardRequest",
"AdsQuery",
"SemanticSerp",
"GenerateContentQuery",
] as const;
/** Append the record separator to a JSON-serializable frame. */
export function encodeFrame(obj: unknown): string {
return JSON.stringify(obj) + RECORD_SEPARATOR;
}
/** Serialized handshake request frame. */
export function handshakeFrame(): string {
return encodeFrame(HANDSHAKE_REQUEST);
}
/** Serialized keepalive ping frame. */
export function keepaliveFrame(): string {
return encodeFrame(KEEPALIVE_PING);
}
/**
* 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.
*/
export function splitFrames(buffer: string): { frames: string[]; rest: string } {
const parts = buffer.split(RECORD_SEPARATOR);
// The last element is either "" (buffer ended on a separator) or a partial frame.
const rest = parts.pop() ?? "";
const frames = parts.filter((p) => p.length > 0);
return { frames, rest };
}
/** Safely JSON.parse a single frame body; returns null on malformed input. */
export function parseFrame(frame: string): Record<string, unknown> | null {
const trimmed = frame.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/**
* A SignalR handshake response is `{}` on success, or `{ error: "..." }` on
* failure. Returns the error string, or null when the handshake succeeded.
*/
export function handshakeError(frame: Record<string, unknown> | null): string | null {
if (!frame) return null;
const err = frame.error;
return typeof err === "string" && err.length > 0 ? err : null;
}
export interface ChatInvocationOptions {
text: string;
/** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */
traceId: string;
/** Per-session id (GUID). */
sessionId: string;
/** Whether this is the first turn of the conversation. */
isStartOfSession?: boolean;
/** Tier-specific option flags; left empty by default (tuned during live validation). */
optionsSets?: string[];
tone?: string;
}
/**
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
* Mirrors the argument shape captured on the individual M365 path in #4042.
*/
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
return {
type: 4,
target: "chat",
invocationId: "0",
arguments: [
{
source: "officeweb",
clientCorrelationId: opts.traceId,
sessionId: opts.sessionId,
optionsSets: opts.optionsSets ?? [],
streamingMode: "ConciseWithPadding",
spokenTextMode: "None",
options: {},
extraExtensionParameters: {},
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES],
sliceIds: [],
threadLevelGptId: {},
traceId: opts.traceId,
isStartOfSession: opts.isStartOfSession ?? true,
clientInfo: {},
message: {
author: "user",
inputMethod: "Keyboard",
text: opts.text,
messageType: "Chat",
},
plugins: [],
isSbsSupported: false,
tone: opts.tone ?? "",
renderReferencesBehindEOS: true,
disconnectBehavior: "",
},
],
};
}
/** True when the frame is a SignalR invocation/streamItem (`type:1`) update. */
export function isUpdateFrame(frame: Record<string, unknown> | null): boolean {
return !!frame && frame.type === 1 && frame.target === "update";
}
/** True when the frame is the SignalR completion (`type:3`) for the chat invocation. */
export function isCompletionFrame(frame: Record<string, unknown> | null): boolean {
return !!frame && frame.type === 3;
}
/** True when an update frame is flagged as the last update of the turn. */
export function isLastUpdate(frame: Record<string, unknown> | null): boolean {
if (!isUpdateFrame(frame)) return false;
const args = (frame as Record<string, unknown>).arguments;
const first = Array.isArray(args) ? (args[0] as Record<string, unknown> | undefined) : undefined;
return first?.isLastUpdate === true;
}
/**
* Extract the accumulated bot text from a `type:1` update frame, reading the last
* bot-authored message's `.text`. Returns null when the frame carries no bot text
* (Progress/Suggestion/ReferencesListComplete updates, throttling-only frames, etc.).
*/
export function extractBotText(frame: Record<string, unknown> | null): string | null {
if (!isUpdateFrame(frame)) return null;
const args = (frame as Record<string, unknown>).arguments;
const first = Array.isArray(args) ? (args[0] as Record<string, unknown> | undefined) : undefined;
const messages = first?.messages;
if (!Array.isArray(messages)) return null;
// Prefer the last bot-authored message with non-empty text.
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i] as Record<string, unknown> | undefined;
if (!m) continue;
const author = m.author;
const text = m.text;
if ((author === "bot" || author === undefined) && typeof text === "string" && text.length > 0) {
return text;
}
}
return null;
}
/**
* BizChat update frames carry the FULL accumulated answer each time, not an
* incremental delta. Given the previously-emitted text and the new accumulated
* text, return the new suffix to stream. When the new text does not extend the
* previous (a replace/rewrite), the whole new text is returned so nothing is lost.
*/
export function incrementalDelta(previous: string, next: string): string {
if (!next) return "";
if (next === previous) return "";
if (next.startsWith(previous)) return next.slice(previous.length);
return next;
}

View File

@@ -0,0 +1,215 @@
import test from "node:test";
import assert from "node:assert/strict";
// #4042 — Microsoft 365 Copilot (individual / Substrate BizChat) frame mapping.
// Fixtures below are taken from @skyzea1's real, sanitized capture of an
// `m365.cloud.microsoft/chat` round-trip (test prompt → one-word "pong" reply).
// These pin the wire format so the executor's encode/decode cannot drift; the
// live socket round-trip is the separate Rule #18 validation gate.
import {
RECORD_SEPARATOR,
encodeFrame,
handshakeFrame,
keepaliveFrame,
splitFrames,
parseFrame,
handshakeError,
buildChatInvocation,
isUpdateFrame,
isCompletionFrame,
isLastUpdate,
extractBotText,
incrementalDelta,
} from "../../open-sse/executors/copilot-m365-frames.ts";
// ── Real captured frames (#4042) ──────────────────────────────────────────
const HANDSHAKE_ACK = {}; // SignalR success ack
const PROGRESS_UPDATE = {
type: 1,
target: "update",
arguments: [{ messages: [{ messageType: "Progress", author: "bot" }] }],
};
const BOT_UPDATE = {
type: 1,
target: "update",
arguments: [
{
messages: [
{
text: "pong",
author: "bot",
responseIdentifier: "Default",
messageId: "00000000-0000-0000-0000-000000000001",
requestId: "trace-id",
adaptiveCards: [
{ type: "AdaptiveCard", version: "1.0", body: [{ type: "TextBlock", text: "pong" }] },
],
sourceAttributions: [],
contentOrigin: "DeepLeo",
},
],
nonce: "nonce",
requestId: "trace-id",
},
],
};
const FINAL_UPDATE = {
type: 1,
target: "update",
arguments: [
{
messages: [
{ text: "pong", author: "bot", sourceAttributions: [], references: {}, contentOrigin: "DeepLeo" },
],
isLastUpdate: true,
requestId: "trace-id",
},
],
};
const FINAL_ITEM = { type: 2, invocationId: "0", item: { messages: [], requestId: "trace-id" } };
const COMPLETION = { type: 3, invocationId: "0" };
// ── Framing ────────────────────────────────────────────────────────────────
test("RECORD_SEPARATOR is the SignalR 0x1e control char", () => {
assert.equal(RECORD_SEPARATOR, String.fromCharCode(0x1e));
assert.equal(RECORD_SEPARATOR.charCodeAt(0), 0x1e);
});
test("handshakeFrame / keepaliveFrame emit the exact SignalR bytes", () => {
assert.equal(handshakeFrame(), `{"protocol":"json","version":1}` + RECORD_SEPARATOR);
assert.equal(keepaliveFrame(), `{"type":6}` + RECORD_SEPARATOR);
});
test("encodeFrame appends the record separator", () => {
assert.equal(encodeFrame({ a: 1 }), `{"a":1}` + RECORD_SEPARATOR);
});
test("splitFrames separates complete frames and keeps the trailing partial", () => {
const buffer = encodeFrame(HANDSHAKE_ACK) + encodeFrame(BOT_UPDATE) + `{"type":1,"par`;
const { frames, rest } = splitFrames(buffer);
assert.equal(frames.length, 2);
assert.deepEqual(parseFrame(frames[0]), {});
assert.equal(isUpdateFrame(parseFrame(frames[1])), true);
assert.equal(rest, `{"type":1,"par`);
});
test("splitFrames returns empty rest when the buffer ends on a separator", () => {
const { frames, rest } = splitFrames(encodeFrame(COMPLETION));
assert.equal(frames.length, 1);
assert.equal(rest, "");
});
// ── Handshake ────────────────────────────────────────────────────────────
test("handshakeError is null on the {} ack and surfaces an error string", () => {
assert.equal(handshakeError(parseFrame(encodeFrame(HANDSHAKE_ACK).slice(0, -1))), null);
assert.equal(handshakeError({ error: "bad handshake" }), "bad handshake");
});
// ── Send (type:4) ──────────────────────────────────────────────────────────
test("buildChatInvocation produces a type:4 chat invocation carrying the user text", () => {
const frame = buildChatInvocation({
text: "protocol capture test. Reply with one word: pong.",
traceId: "trace-id",
sessionId: "session-id",
isStartOfSession: true,
});
assert.equal(frame.type, 4);
assert.equal(frame.target, "chat");
assert.equal(frame.invocationId, "0");
const arg = (frame.arguments as Array<Record<string, unknown>>)[0];
assert.equal(arg.source, "officeweb");
assert.equal(arg.streamingMode, "ConciseWithPadding");
assert.equal(arg.traceId, "trace-id");
assert.equal(arg.clientCorrelationId, "trace-id");
assert.equal(arg.sessionId, "session-id");
assert.equal(arg.isStartOfSession, true);
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.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 wire = encodeFrame(frame);
assert.ok(wire.endsWith(RECORD_SEPARATOR));
const { frames } = splitFrames(wire);
assert.deepEqual(parseFrame(frames[0]), frame);
});
// ── Response decode (type:1/2/3) ─────────────────────────────────────────
test("isUpdateFrame / isCompletionFrame classify the captured frames", () => {
assert.equal(isUpdateFrame(BOT_UPDATE), true);
assert.equal(isUpdateFrame(FINAL_UPDATE), true);
assert.equal(isUpdateFrame(COMPLETION), false);
assert.equal(isUpdateFrame(FINAL_ITEM), false);
assert.equal(isCompletionFrame(COMPLETION), true);
assert.equal(isCompletionFrame(BOT_UPDATE), false);
assert.equal(isCompletionFrame(FINAL_ITEM), false); // type:2 is the final item, not completion
});
test("isLastUpdate only fires on the isLastUpdate:true update", () => {
assert.equal(isLastUpdate(BOT_UPDATE), false);
assert.equal(isLastUpdate(FINAL_UPDATE), true);
assert.equal(isLastUpdate(COMPLETION), false);
});
test("extractBotText reads the bot answer and ignores Progress frames", () => {
assert.equal(extractBotText(BOT_UPDATE), "pong");
assert.equal(extractBotText(FINAL_UPDATE), "pong");
assert.equal(extractBotText(PROGRESS_UPDATE), null);
assert.equal(extractBotText(COMPLETION), null);
});
// ── Accumulated → incremental delta ─────────────────────────────────────
test("incrementalDelta emits only the new suffix of accumulated text", () => {
assert.equal(incrementalDelta("", "pong"), "pong");
assert.equal(incrementalDelta("pong", "pong"), "");
assert.equal(incrementalDelta("po", "pong"), "ng");
assert.equal(incrementalDelta("", ""), "");
});
test("incrementalDelta falls back to the full text on a non-extending replace", () => {
assert.equal(incrementalDelta("abc", "xyz"), "xyz");
});
// ── End-to-end decode of the captured stream ─────────────────────────────
test("decoding the captured frame sequence reconstructs the bot answer once", () => {
const wire =
encodeFrame(HANDSHAKE_ACK) +
encodeFrame(PROGRESS_UPDATE) +
encodeFrame(BOT_UPDATE) +
encodeFrame(FINAL_UPDATE) +
encodeFrame(FINAL_ITEM) +
encodeFrame(COMPLETION);
const { frames } = splitFrames(wire);
let emitted = "";
let prev = "";
let completed = false;
for (const raw of frames) {
const frame = parseFrame(raw);
if (isUpdateFrame(frame)) {
const text = extractBotText(frame);
if (text != null) {
emitted += incrementalDelta(prev, text);
prev = text;
}
} else if (isCompletionFrame(frame)) {
completed = true;
}
}
assert.equal(emitted, "pong");
assert.equal(completed, true);
});

View File

@@ -0,0 +1,109 @@
import test from "node:test";
import assert from "node:assert/strict";
// #4042 — M365 Copilot (individual) connection helpers: credential resolution,
// WS URL building, token redaction, and prompt flattening. Pure functions, no
// live socket — the round-trip is the separate Rule #18 validation gate.
import {
M365_INDIVIDUAL_DEFAULTS,
newChatSessionId,
resolveConnectionParams,
buildWsUrl,
redactWsUrl,
buildPrompt,
} from "../../open-sse/executors/copilot-m365-connection.ts";
// ── Credential resolution ────────────────────────────────────────────────
test("resolveConnectionParams errors when the access_token is missing", () => {
const r = resolveConnectionParams(undefined);
assert.ok("error" in r);
assert.match((r as { error: string }).error, /access_token/i);
});
test("resolveConnectionParams errors when the Chathub path is missing", () => {
const r = resolveConnectionParams({ apiKey: "tok" });
assert.ok("error" in r);
assert.match((r as { error: string }).error, /Chathub path/i);
});
test("resolveConnectionParams reads token from apiKey and path from providerSpecificData", () => {
const r = resolveConnectionParams({
apiKey: "opaque-jwe-token",
providerSpecificData: { chathubPath: "user-oid@tenant-id" },
});
assert.ok(!("error" in r));
const p = r as { host: string; chathubPath: string; accessToken: string };
assert.equal(p.accessToken, "opaque-jwe-token");
assert.equal(p.chathubPath, "user-oid@tenant-id");
assert.equal(p.host, M365_INDIVIDUAL_DEFAULTS.host);
});
test("resolveConnectionParams accepts access_token in providerSpecificData and a custom host", () => {
const r = resolveConnectionParams({
providerSpecificData: {
access_token: "tok2",
userTenant: "u@t",
host: "substrate.svc.cloud.microsoft",
},
});
assert.ok(!("error" in r));
const p = r as { host: string; chathubPath: string; accessToken: string };
assert.equal(p.accessToken, "tok2");
assert.equal(p.chathubPath, "u@t");
assert.equal(p.host, "substrate.svc.cloud.microsoft");
});
// ── WS URL building ──────────────────────────────────────────────────────
test("buildWsUrl targets the substrate Chathub with the individual-tier query", () => {
const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "TOK" });
assert.ok(url.startsWith("wss://substrate.office.com/m365Copilot/Chathub/u@t?"));
const qs = new URLSearchParams(url.split("?")[1]);
assert.equal(qs.get("licenseType"), "Starter");
assert.equal(qs.get("agent"), "web");
assert.equal(qs.get("scenario"), "OfficeWebPaidConsumerCopilot");
assert.equal(qs.get("source"), "officeweb");
assert.equal(qs.get("access_token"), "TOK");
// chatsessionid == XRoutingParameterSessionKey == clientrequestid (same value)
const sid = qs.get("chatsessionid");
assert.ok(sid && /^[0-9a-f]{32}$/.test(sid));
assert.equal(qs.get("XRoutingParameterSessionKey"), sid);
assert.equal(qs.get("clientrequestid"), sid);
});
test("redactWsUrl strips the access_token so the URL is safe to log", () => {
const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "SECRET" });
const redacted = redactWsUrl(url);
assert.ok(!redacted.includes("SECRET"), "token must not survive redaction");
assert.match(redacted, /access_token=REDACTED/);
});
test("newChatSessionId is 32 lowercase hex chars", () => {
const id = newChatSessionId();
assert.match(id, /^[0-9a-f]{32}$/);
assert.notEqual(newChatSessionId(), id);
});
// ── Prompt flattening ────────────────────────────────────────────────────
test("buildPrompt returns the last user message", () => {
const prompt = buildPrompt({
messages: [
{ role: "user", content: "first" },
{ role: "user", content: "second" },
],
});
assert.equal(prompt, "second");
});
test("buildPrompt prepends system instructions", () => {
const prompt = buildPrompt({
messages: [
{ role: "system", content: "Be terse." },
{ role: "user", content: "hi" },
],
});
assert.match(prompt, /\[System Instructions\]\nBe terse\.\n\nhi$/);
});