fix(providers): copilot-m365-web fails loudly on empty turns + tier-aware enterprise invocation (#7858, #7870) (#7958)

#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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-21 08:39:37 -03:00
committed by GitHub
parent 5996acdcc7
commit ec5b24b986
6 changed files with 362 additions and 2 deletions

View File

@@ -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)

View File

@@ -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<M365ConnectionParams, "scenario" | "isEdu" | "licenseType" | "agent"> {
): Pick<M365ConnectionParams, "scenario" | "isEdu" | "licenseType" | "agent" | "tier"> {
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,
};
}

View File

@@ -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<string,
spokenTextMode: "None",
options: {},
extraExtensionParameters: {},
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES],
allowedMessageTypes: opts.allowedMessageTypes
? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES],
sliceIds: [],
threadLevelGptId: {},
traceId: opts.traceId,

View File

@@ -16,8 +16,10 @@ import {
handshakeError,
handshakeFrame,
isCompletionFrame,
isUpdateFrame,
keepaliveFrame,
parseFrame,
resolveChatInvocationOverrides,
splitFrames,
} from "./copilot-m365-frames.ts";
@@ -42,6 +44,19 @@ function sseChunk(model: string, delta: JsonRecord, finishReason: string | null
})}\n\n`;
}
/**
* Describe an unrecognized `type:1 target:update` frame by its top-level argument keys
* only (#7858 AC: log shape, never content/tokens/cookies) so the next unrecognized-shape
* report arrives with the data needed to add a handler.
*/
function describeUpdateFrameShape(frame: Record<string, unknown> | null): string | null {
if (!isUpdateFrame(frame) || !frame) return null;
const args = frame.arguments;
const first = Array.isArray(args) ? (args[0] as Record<string, unknown> | 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<ReadableStream<Uint8Array>> {
@@ -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,
});

View File

@@ -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<string, Listener[]>();
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<string, unknown>;
} 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, "");
});

View File

@@ -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<Record<string, unknown>>) {
return class FakeWS {
private handlers: Record<string, (arg?: unknown) => 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}`);
});