From ec5b24b986e3e2f2f37bf95d04b67f1d95354716 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:39:37 -0300 Subject: [PATCH] fix(providers): copilot-m365-web fails loudly on empty turns + tier-aware enterprise invocation (#7858, #7870) (#7958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7858 — accumulateBotContent() silently returned an empty delta for any unrecognized frame shape, and finish() only had a fallback for the type:2 finalResultMessage case; a turn with no content in ANY known shape closed with a bare `stop` + `[DONE]`, indistinguishable from a genuine empty answer. finish() now emits a sanitized error (Hard Rule #12) naming the resolved tier and the likely causes, and unrecognized update-frame shapes are logged by argument KEY only (never content, tokens, or cookies). #7870 — the enterprise tier only changed buildWsUrl() query params; buildChatInvocation() always fell back to the consumer M365_DEFAULT_OPTION_SETS (which declares the MSA-only enable_msa_user flag) and tone:"". resolveConnectionParams()/resolveTierOverrides() now also resolve and surface the tier itself, threaded through wsChat() -> sendChat() -> buildChatInvocation() via a new resolveChatInvocationOverrides() helper, so an enterprise-tier invocation declares the enterprise_*/bizchat_* option sets, the wider allowedMessageTypes captured from the real enterprise HAR (Discussion #7850), and tone:"Magic" — while individual and EDU payloads stay byte-identical to today. Regression tests: tests/unit/copilot-m365-web-silent-empty-7858.test.ts, tests/unit/copilot-m365-enterprise-invocation-7870.test.ts. --- .../7858-copilot-m365-web-empty-frame.md | 1 + open-sse/executors/copilot-m365-connection.ts | 5 +- open-sse/executors/copilot-m365-frames.ts | 65 ++++++- open-sse/executors/copilot-m365-web.ts | 41 +++++ ...ot-m365-enterprise-invocation-7870.test.ts | 160 ++++++++++++++++++ ...copilot-m365-web-silent-empty-7858.test.ts | 92 ++++++++++ 6 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/7858-copilot-m365-web-empty-frame.md create mode 100644 tests/unit/copilot-m365-enterprise-invocation-7870.test.ts create mode 100644 tests/unit/copilot-m365-web-silent-empty-7858.test.ts diff --git a/changelog.d/fixes/7858-copilot-m365-web-empty-frame.md b/changelog.d/fixes/7858-copilot-m365-web-empty-frame.md new file mode 100644 index 0000000000..d24c5d86f9 --- /dev/null +++ b/changelog.d/fixes/7858-copilot-m365-web-empty-frame.md @@ -0,0 +1 @@ +- fix(providers): copilot-m365-web now fails loudly on a fully-empty turn instead of a silent `stop`, and the enterprise tier threads its own `optionsSets`/`tone`/`allowedMessageTypes` into the chat invocation instead of only changing the WS URL (#7858, #7870) diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index 80a06a75ec..0c5303c250 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -108,6 +108,8 @@ export interface M365ConnectionParams { isEdu?: string; licenseType?: string; agent?: string; + /** Resolved tier name (#7870) — threads into the chat invocation payload, not just the URL. */ + tier?: "edu" | "enterprise"; } /** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */ @@ -196,7 +198,7 @@ export function resolveConnectionParams( */ function resolveTierOverrides( psd: JsonRecord -): Pick { +): Pick { const tier = typeof psd.tier === "string" ? psd.tier.toLowerCase() : ""; const isEduTier = tier === "edu" || tier === "included"; const isEnterpriseTier = tier === "enterprise" || tier === "work"; @@ -217,6 +219,7 @@ function resolveTierOverrides( agent: (typeof psd.agent === "string" && psd.agent) || (isEnterpriseTier ? M365_ENTERPRISE_OVERRIDES.agent : undefined), + tier: isEduTier ? "edu" : isEnterpriseTier ? "enterprise" : undefined, }; } diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index d4544aea20..33074c069f 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -40,6 +40,40 @@ export const ALLOWED_MESSAGE_TYPES = [ "GenerateContentQuery", ] as const; +/** + * Enterprise / "work" tier option sets (#7870), captured from @OfflinePing's HAR of the + * real Microsoft 365 Copilot for work web UI (Discussion #7850). Unlike + * {@link M365_DEFAULT_OPTION_SETS} (a consumer/MSA set), this omits `enable_msa_user` and + * the `cwc_*` consumer entries and declares the `enterprise_*`/`bizchat_*` work-surface + * flags the capture showed — the individual/consumer set never produces a turn on an AAD + * enterprise tenant because it advertises the wrong account surface. + */ +export const M365_ENTERPRISE_OPTION_SETS = [ + "enterprise_flux_image", + "enterprise_flux_web", + "enterprise_flux_work", + "enterprise_toolbox_with_skdsstore", + "enterprise_pagination_support", + "enterprise_flux_work_code_interpreter", + "enterprise_code_interpreter_citation_fix", + "bizchat_enable_federated_connectors", + "at_mention_plugins_enable", +] as const; + +/** + * Additional SignalR message types observed on the enterprise capture beyond + * {@link ALLOWED_MESSAGE_TYPES} (#7870) — the server actively emits `ReferencesListComplete` + * on that tenant, a type we did not previously declare as allowed. + */ +export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [ + "ReferencesListComplete", + "EndOfRequest", + "MemoryUpdate", + "TriggerPlugin", + "AuthError", + "SwitchRespondingEndpoint", +] as const; + export const M365_DEFAULT_OPTION_SETS = [ "search_result_progress_messages_with_search_queries", "update_textdoc_response_after_streaming", @@ -130,6 +164,33 @@ export interface ChatInvocationOptions { /** Tier-specific option flags; left empty by default (tuned during live validation). */ optionsSets?: string[]; tone?: string; + /** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */ + allowedMessageTypes?: readonly string[]; +} + +/** + * Resolve the tier-specific `optionsSets` / `tone` / `allowedMessageTypes` overrides for + * the `type:4` chat invocation (#7870). Mirrors how `resolveConnectionParams`/`buildWsUrl` + * already branch on tier for the WS URL — this is the request-payload counterpart so an + * enterprise tier actually changes what is sent, not just where it is sent. + */ +export function resolveChatInvocationOverrides(tier: string | undefined): { + optionsSets: string[]; + tone: string; + allowedMessageTypes: readonly string[]; +} { + if (tier === "enterprise") { + return { + optionsSets: [...M365_ENTERPRISE_OPTION_SETS], + tone: "Magic", + allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES, ...M365_ENTERPRISE_EXTRA_MESSAGE_TYPES], + }; + } + return { + optionsSets: [...M365_DEFAULT_OPTION_SETS], + tone: "", + allowedMessageTypes: ALLOWED_MESSAGE_TYPES, + }; } /** @@ -151,7 +212,9 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record | null): string | null { + if (!isUpdateFrame(frame) || !frame) return null; + const args = frame.arguments; + const first = Array.isArray(args) ? (args[0] as Record | undefined) : undefined; + if (!first) return null; + return Object.keys(first).join(","); +} + function errorResponse(message: string, status = 502): Response { return new Response(JSON.stringify({ error: { message } }), { status, @@ -58,6 +73,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { wsUrl: string; prompt: string; model: string; + tier?: string; signal?: AbortSignal; log?: ExecutorLog | null; }): Promise> { @@ -96,6 +112,21 @@ export class CopilotM365WebExecutor extends BaseExecutor { controller.enqueue( encoder.encode(sseChunk(input.model, { content: finalResultMessage })) ); + } else if (!previousText && !finalResultMessage) { + // #7858 — a turn that completed with no content in ANY known shape is + // indistinguishable, from the outside, from a genuine successful-but-empty + // reply. Fail loudly instead of a silent `stop`, per Hard Rule #12. + const tierNote = input.tier ? `resolved tier: ${input.tier}` : "resolved tier: individual (default)"; + const message = sanitizeErrorMessage( + `Microsoft 365 Copilot turn completed with no content in any known frame ` + + `shape (${tierNote}). Possible causes: an unrecognized frame shape for ` + + `this tenant, or a misconfigured tier.` + ); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ error: { message } })}\n\n`) + ); + controller.close(); + return; } controller.enqueue(encoder.encode(sseChunk(input.model, {}, "stop"))); controller.enqueue(encoder.encode("data: [DONE]\n\n")); @@ -135,6 +166,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { const sendChat = () => { ws?.send(keepaliveFrame()); + const overrides = resolveChatInvocationOverrides(input.tier); ws?.send( encodeFrame( buildChatInvocation({ @@ -142,6 +174,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { traceId, sessionId, isStartOfSession: true, + ...overrides, }) ) ); @@ -179,6 +212,13 @@ export class CopilotM365WebExecutor extends BaseExecutor { } const { delta, next } = accumulateBotContent(previousText, frame); + if (!delta && next === previousText) { + // #7858 AC2/AC3 — log unrecognized-shape update frames by KEY only, so + // the next report ships the data needed to add a handler without a + // manual capture round-trip. Never log message content, tokens, cookies. + const shape = describeUpdateFrameShape(frame); + if (shape) log?.debug?.("M365_WS", `unrecognized update frame keys: ${shape}`); + } previousText = next; if (delta) { controller.enqueue(encoder.encode(sseChunk(input.model, { content: delta }))); @@ -265,6 +305,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { wsUrl, prompt, model, + tier: connectionParams.tier, signal: input.signal ?? undefined, log: input.log, }); diff --git a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts new file mode 100644 index 0000000000..97f08acc98 --- /dev/null +++ b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts @@ -0,0 +1,160 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CopilotM365WebExecutor, + __setCopilotM365WebSocketForTesting, +} from "../../open-sse/executors/copilot-m365-web.ts"; +import { encodeFrame } from "../../open-sse/executors/copilot-m365-frames.ts"; + +type Listener = (...args: unknown[]) => void; + +class MockM365WebSocket { + static instances: MockM365WebSocket[] = []; + sent: string[] = []; + closed = false; + listeners = new Map(); + + constructor(public url: string, public options: unknown) { + MockM365WebSocket.instances.push(this); + queueMicrotask(() => this.emit("open")); + } + + on(event: string, listener: Listener): this { + const listeners = this.listeners.get(event) ?? []; + listeners.push(listener); + this.listeners.set(event, listeners); + return this; + } + + send(data: string): void { + this.sent.push(String(data)); + const parsed = JSON.parse(String(data).replace(/\x1e$/, "")); + if (parsed.protocol === "json") { + queueMicrotask(() => this.emit("message", Buffer.from(encodeFrame({})))); + return; + } + if (parsed.type === 4 && parsed.target === "chat") { + queueMicrotask(() => { + this.emit( + "message", + Buffer.from( + encodeFrame({ + type: 1, + target: "update", + arguments: [{ messages: [{ text: "hi", author: "bot" }], isLastUpdate: true }], + }) + + encodeFrame({ type: 2, invocationId: "0", item: { messages: [] } }) + + encodeFrame({ type: 3, invocationId: "0" }) + ) + ); + }); + } + } + + close(): void { + this.closed = true; + } + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } +} + +async function sendChatInvocation(tier: string | undefined) { + MockM365WebSocket.instances = []; + const restore = __setCopilotM365WebSocketForTesting( + MockM365WebSocket as unknown as typeof import("ws").default + ); + try { + const executor = new CopilotM365WebExecutor(); + await executor.execute({ + model: "copilot-m365", + stream: true, + body: { messages: [{ role: "user", content: "hello" }] }, + credentials: { + apiKey: "redacted-token", + providerSpecificData: { + chathubPath: "redacted-user@redacted-tenant", + ...(tier ? { tier } : {}), + }, + }, + } as never); + + assert.equal(MockM365WebSocket.instances.length, 1); + const sentFrames = MockM365WebSocket.instances[0].sent; + const chatFrameRaw = sentFrames + .map((f) => f.replace(/\x1e$/, "")) + .map((f) => { + try { + return JSON.parse(f); + } catch { + return null; + } + }) + .find((f) => f && f.type === 4 && f.target === "chat"); + + assert.ok(chatFrameRaw, "expected a type:4 chat invocation frame to be sent"); + return chatFrameRaw.arguments[0] as Record; + } finally { + restore(); + } +} + +test("#7870: enterprise-tier chat invocation must not carry the consumer enable_msa_user optionsSet", async () => { + const invocationArgs = await sendChatInvocation("enterprise"); + const optionsSets = invocationArgs.optionsSets as string[]; + assert.ok( + !optionsSets.includes("enable_msa_user"), + `enterprise-tier invocation must not declare enable_msa_user (MSA/consumer flag); got optionsSets=${JSON.stringify(optionsSets)}` + ); +}); + +test("#7870: enterprise-tier chat invocation declares the enterprise_flux_work option set", async () => { + const invocationArgs = await sendChatInvocation("enterprise"); + const optionsSets = invocationArgs.optionsSets as string[]; + assert.ok( + optionsSets.includes("enterprise_flux_work"), + `expected enterprise_flux_work in optionsSets, got=${JSON.stringify(optionsSets)}` + ); +}); + +test("#7870: enterprise-tier chat invocation widens allowedMessageTypes to include ReferencesListComplete", async () => { + const invocationArgs = await sendChatInvocation("enterprise"); + const allowedMessageTypes = invocationArgs.allowedMessageTypes as string[]; + assert.ok( + allowedMessageTypes.includes("ReferencesListComplete"), + `expected ReferencesListComplete in allowedMessageTypes, got=${JSON.stringify(allowedMessageTypes)}` + ); +}); + +test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () => { + const invocationArgs = await sendChatInvocation("enterprise"); + assert.equal(invocationArgs.tone, "Magic"); +}); + +test("#7870: individual (no tier) chat invocation payload stays byte-identical to today", async () => { + const invocationArgs = await sendChatInvocation(undefined); + const optionsSets = invocationArgs.optionsSets as string[]; + assert.ok(optionsSets.includes("enable_msa_user")); + assert.equal(invocationArgs.tone, ""); + assert.deepEqual(invocationArgs.allowedMessageTypes, [ + "Chat", + "Suggestion", + "InternalSearchQuery", + "Disengaged", + "InternalLoaderMessage", + "Progress", + "GeneratedCode", + "RenderCardRequest", + "AdsQuery", + "SemanticSerp", + "GenerateContentQuery", + ]); +}); + +test("#7870: EDU-tier chat invocation payload stays byte-identical to today (unaffected by enterprise change)", async () => { + const invocationArgs = await sendChatInvocation("edu"); + const optionsSets = invocationArgs.optionsSets as string[]; + assert.ok(optionsSets.includes("enable_msa_user")); + assert.equal(invocationArgs.tone, ""); +}); diff --git a/tests/unit/copilot-m365-web-silent-empty-7858.test.ts b/tests/unit/copilot-m365-web-silent-empty-7858.test.ts new file mode 100644 index 0000000000..7fa72b708b --- /dev/null +++ b/tests/unit/copilot-m365-web-silent-empty-7858.test.ts @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + CopilotM365WebExecutor, + __setCopilotM365WebSocketForTesting, +} from "../../open-sse/executors/copilot-m365-web.ts"; +import { encodeFrame } from "../../open-sse/executors/copilot-m365-frames.ts"; + +function makeFakeWsCtor(frames: Array>) { + return class FakeWS { + private handlers: Record void> = {}; + constructor(_url: string) { + setImmediate(() => this.handlers.open?.()); + } + on(event: string, cb: (arg?: unknown) => void) { + this.handlers[event] = cb; + return this; + } + send(data: unknown) { + const str = typeof data === "string" ? data : String(data); + if (str.includes('"protocol":"json"')) { + setImmediate(() => this.handlers.message?.(Buffer.from(encodeFrame({})))); + } else if (str.includes('"target":"chat"')) { + setImmediate(() => { + for (const frame of frames) this.handlers.message?.(Buffer.from(encodeFrame(frame))); + }); + } + } + close() {} + }; +} + +test("copilot-m365-web: unrecognized frame shape must surface an error, not a silent stop [#7858]", async () => { + const frames = [ + { type: 1, target: "update", arguments: [{ someUnknownField: "not a known shape" }] }, + { type: 3 }, + ]; + const restore = __setCopilotM365WebSocketForTesting(makeFakeWsCtor(frames) as never); + let body: string; + try { + const { response } = await new CopilotM365WebExecutor().execute({ + model: "copilot-m365", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: { + apiKey: "access_token=test-token", + providerSpecificData: { chathubPath: "user-oid@tenant-id" }, + }, + log: null, + } as never); + body = await response.text(); + } finally { + restore(); + } + + assert.ok( + body.includes('"error"'), + `expected the stream to carry an explicit error for a fully-empty turn, got: ${body}` + ); +}); + +test("copilot-m365-web: a legitimate content-bearing turn is unaffected [#7858 regression guard]", async () => { + const frames = [ + { + type: 1, + target: "update", + arguments: [{ messages: [{ author: "bot", text: "hello there" }] }], + }, + { type: 3 }, + ]; + const restore = __setCopilotM365WebSocketForTesting(makeFakeWsCtor(frames) as never); + let body: string; + try { + const { response } = await new CopilotM365WebExecutor().execute({ + model: "copilot-m365", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: { + apiKey: "access_token=test-token", + providerSpecificData: { chathubPath: "user-oid@tenant-id" }, + }, + log: null, + } as never); + body = await response.text(); + } finally { + restore(); + } + + assert.ok(!body.includes('"error"'), `expected no error for a content-bearing turn, got: ${body}`); + assert.ok(body.includes("hello there"), `expected the streamed content, got: ${body}`); + assert.ok(body.includes('"finish_reason":"stop"'), `expected a stop chunk, got: ${body}`); +});