mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Land M365 pure modules ahead of draft #4400
This commit is contained in:
committed by
GitHub
parent
4e9d4f4eb7
commit
02482b3e1e
116
open-sse/executors/copilot-m365-connection.ts
Normal file
116
open-sse/executors/copilot-m365-connection.ts
Normal 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;
|
||||
}
|
||||
201
open-sse/executors/copilot-m365-frames.ts
Normal file
201
open-sse/executors/copilot-m365-frames.ts
Normal 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;
|
||||
}
|
||||
215
tests/unit/m365-bizchat-frames-4042.test.ts
Normal file
215
tests/unit/m365-bizchat-frames-4042.test.ts
Normal 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);
|
||||
});
|
||||
109
tests/unit/m365-connection-4042.test.ts
Normal file
109
tests/unit/m365-connection-4042.test.ts
Normal 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$/);
|
||||
});
|
||||
Reference in New Issue
Block a user