mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
Validado no worktree combinado: mesmos gates + 36 testes focados verdes. Feature bem documentada e testada (tool calling completo para copilot-m365-web via SignalR, incluindo keepalives e detecção de erro silencioso). CI vermelho é o base-red já rastreado em #9985.
683 lines
26 KiB
TypeScript
683 lines
26 KiB
TypeScript
/**
|
|
* 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, ... },
|
|
* 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.
|
|
*/
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
/** 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 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",
|
|
"Disengaged",
|
|
"Progress",
|
|
"EndOfRequest",
|
|
"InternalLoaderMessage",
|
|
] 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;
|
|
|
|
/**
|
|
* 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",
|
|
"cwcfluxgptv",
|
|
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
|
|
"gptvnorm2048",
|
|
"cwc_fileupload_odb",
|
|
"update_memory_plugin",
|
|
"add_custom_instructions",
|
|
"cwc_flux_v3",
|
|
"flux_v3_progress_messages",
|
|
"enable_batch_token_processing",
|
|
"enable_gg_gpt",
|
|
] 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);
|
|
}
|
|
|
|
/**
|
|
* #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.
|
|
*/
|
|
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-invocation trace id (GUID). */
|
|
traceId: string;
|
|
/** 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; 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 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;
|
|
/** Client-declared tool plugins (see {@link clientPlugins}); defaults to `[]`. */
|
|
plugins?: JsonRecord[];
|
|
/** OpenAI `tool_choice` echoed to the substrate; defaults to `null`. */
|
|
toolChoice?: unknown;
|
|
/** Tool-use nudge sent as `customInstructions` when tools are declared. */
|
|
customInstructions?: string;
|
|
}
|
|
|
|
/** A client-declared tool in the normalized shape produced by `extractToolSpec`. */
|
|
export interface M365ToolDecl {
|
|
name: string;
|
|
description: string;
|
|
parameters: JsonRecord | null;
|
|
}
|
|
|
|
/**
|
|
* Map normalized OpenAI function tools to the M365 `plugins[]` invocation entries
|
|
* (`{Id, Source:"API", Description, Parameters}`), mirroring the community M365
|
|
* convention. Entries without a name are skipped by the extractor upstream.
|
|
*/
|
|
export function clientPlugins(tools: M365ToolDecl[]): JsonRecord[] {
|
|
return tools.map((t) => ({
|
|
Id: t.name,
|
|
Source: "API",
|
|
Description: t.description,
|
|
Parameters: t.parameters ?? {},
|
|
}));
|
|
}
|
|
|
|
/** True when `toolChoice` permits calling `name` (string / typed / "required"/"auto"). */
|
|
function toolChoiceAllows(toolChoice: unknown, name: string): boolean {
|
|
if (toolChoice == null || toolChoice === "auto" || toolChoice === "required") return true;
|
|
if (typeof toolChoice === "string") return toolChoice === name;
|
|
const fn = (toolChoice as JsonRecord)?.function as JsonRecord | undefined;
|
|
return typeof fn?.name === "string" && fn.name === name;
|
|
}
|
|
|
|
/** A tool call parsed from the model's fenced-block or router output. */
|
|
export interface M365ParsedToolCall {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
/** JSON-stringified arguments object, as the OpenAI `tool_calls` shape expects. */
|
|
arguments: string;
|
|
}
|
|
|
|
const SHELL_TOOL_NAMES = ["bash", "sh", "shell", "powershell", "cmd"] as const;
|
|
const FENCED_BLOCK = /```([A-Za-z0-9_-]+)[ \t]*\r?\n([\s\S]*?)\r?\n```/g;
|
|
|
|
/**
|
|
* Parse the model's fenced-block tool calls out of a completed turn
|
|
* (```` ```toolname\n{json args}\n``` ```` — the protocol taught by the prompt).
|
|
* Only names the client actually declared are accepted (undeclared names such as
|
|
* a hallucinated `unknown_tool` must never reach the caller), and `tool_choice`
|
|
* restrictions are enforced the same way. A shell-family block emitted for a
|
|
* DECLARED shell tool is normalized into `{command: "..."}`.
|
|
*/
|
|
export function parseFencedToolCalls(
|
|
text: string,
|
|
tools: M365ToolDecl[],
|
|
toolChoice: unknown
|
|
): M365ParsedToolCall[] {
|
|
const allowed = new Set(tools.map((t) => t.name));
|
|
const declaredShell = SHELL_TOOL_NAMES.find((n) => allowed.has(n));
|
|
const out: M365ParsedToolCall[] = [];
|
|
for (const m of text.matchAll(FENCED_BLOCK)) {
|
|
const name = m[1]!;
|
|
const body = m[2]!.trim();
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(body);
|
|
} catch {
|
|
parsed = undefined;
|
|
}
|
|
// Shell-family blocks: keep only for a declared shell tool, normalizing a
|
|
// plain-text body (or {"command": ...}) into the canonical arguments object.
|
|
if ((SHELL_TOOL_NAMES as readonly string[]).includes(name)) {
|
|
const target = allowed.has(name) ? name : declaredShell;
|
|
if (!target) continue;
|
|
const args =
|
|
parsed && typeof parsed === "object" && "command" in (parsed as JsonRecord)
|
|
? (parsed as JsonRecord)
|
|
: { command: body };
|
|
out.push({
|
|
id: `call_${crypto.randomUUID()}`,
|
|
type: "function",
|
|
name: target,
|
|
arguments: JSON.stringify(args),
|
|
});
|
|
continue;
|
|
}
|
|
if (!allowed.has(name) || !toolChoiceAllows(toolChoice, name)) continue;
|
|
if (parsed == null || typeof parsed !== "object") continue;
|
|
out.push({
|
|
id: `call_${crypto.randomUUID()}`,
|
|
type: "function",
|
|
name,
|
|
arguments: JSON.stringify(parsed),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A router-turn decision: `decided:false` means the output was unparseable. */
|
|
export interface M365RouterDecision {
|
|
decided: boolean;
|
|
calls: M365ParsedToolCall[];
|
|
}
|
|
|
|
function allowedName(tools: M365ToolDecl[], name: string): boolean {
|
|
return tools.some((t) => t.name === name);
|
|
}
|
|
|
|
function validCall(
|
|
name: string,
|
|
args: unknown,
|
|
tools: M365ToolDecl[],
|
|
toolChoice: unknown
|
|
): M365ParsedToolCall | null {
|
|
if (!name || !allowedName(tools, name) || !toolChoiceAllows(toolChoice, name)) return null;
|
|
if (!args || typeof args !== "object") return null;
|
|
return {
|
|
id: `call_${crypto.randomUUID()}`,
|
|
type: "function",
|
|
name,
|
|
arguments: JSON.stringify(args),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse the router turn's decision (`CALL_TOOL: name({...})` lines /
|
|
* `NO_TOOL_NEEDED`), validating every call against the declared tools and
|
|
* `tool_choice`. Falls back to the `{"calls":[...]}` JSON envelope. Returns
|
|
* `decided:false` when the output is neither shape, so the caller can fall
|
|
* through to a plain answer turn instead of guessing.
|
|
*/
|
|
export function parseToolRouterDecision(
|
|
text: string,
|
|
tools: M365ToolDecl[],
|
|
toolChoice: unknown
|
|
): M365RouterDecision {
|
|
const trimmed = text.trim();
|
|
const calls: M365ParsedToolCall[] = [];
|
|
for (const line of trimmed.split(/\r?\n/)) {
|
|
const m = /^CALL_TOOL:\s*(.+)$/i.exec(line.trim());
|
|
if (!m) continue;
|
|
const rest = m[1]!;
|
|
const start = rest.indexOf("(");
|
|
const end = rest.lastIndexOf(")");
|
|
if (start <= 0 || end <= start) continue;
|
|
const name = rest.slice(0, start).trim();
|
|
try {
|
|
const args = JSON.parse(rest.slice(start + 1, end));
|
|
const call = validCall(name, args, tools, toolChoice);
|
|
if (call) calls.push(call);
|
|
} catch {
|
|
/* malformed JSON on this line — skip */
|
|
}
|
|
}
|
|
if (calls.length > 0) return { decided: true, calls };
|
|
if (/^no_tool_needed$/i.test(trimmed) || trimmed.toLowerCase().includes("no_tool_needed")) {
|
|
return { decided: true, calls: [] };
|
|
}
|
|
// Fallback: the {"calls":[{"name","arguments"}]} envelope, optionally fenced.
|
|
let probe = trimmed;
|
|
const fence = probe.indexOf("```");
|
|
if (fence >= 0) {
|
|
probe = probe
|
|
.slice(fence + 3)
|
|
.replace(/```$/, "")
|
|
.trim();
|
|
probe = probe.replace(/^(json|JSON)\s*/, "");
|
|
}
|
|
const start = probe.indexOf("{");
|
|
const end = probe.lastIndexOf("}");
|
|
if (start >= 0 && end > start) {
|
|
try {
|
|
const parsed = JSON.parse(probe.slice(start, end + 1)) as {
|
|
calls?: Array<{ name?: unknown; arguments?: unknown }>;
|
|
};
|
|
if (Array.isArray(parsed.calls)) {
|
|
for (const c of parsed.calls) {
|
|
const call = validCall(
|
|
typeof c?.name === "string" ? c.name : "",
|
|
c?.arguments,
|
|
tools,
|
|
toolChoice
|
|
);
|
|
if (call) calls.push(call);
|
|
}
|
|
return { decided: true, calls };
|
|
}
|
|
} catch {
|
|
/* not JSON — undecided */
|
|
}
|
|
}
|
|
return { decided: false, calls: [] };
|
|
}
|
|
|
|
/**
|
|
* 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[];
|
|
disconnectBehavior: string | undefined;
|
|
} {
|
|
if (tier === "enterprise") {
|
|
return {
|
|
optionsSets: [...M365_ENTERPRISE_OPTION_SETS],
|
|
tone: "Magic",
|
|
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES, ...M365_ENTERPRISE_EXTRA_MESSAGE_TYPES],
|
|
disconnectBehavior: "continue",
|
|
};
|
|
}
|
|
return {
|
|
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
|
// #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,
|
|
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
|
|
disconnectBehavior: 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, `magic`
|
|
* otherwise) resolved by {@link resolveChatInvocationOverrides}.
|
|
*/
|
|
export const M365_MODEL_TONE_MAP: Readonly<Record<string, string>> = {
|
|
"copilot-m365-claude-opus": "Claude_Opus",
|
|
"copilot-m365-gpt-5-6-reasoning": "Gpt_5_6_Reasoning",
|
|
"copilot-m365-gpt-5-5-chat": "Gpt_5_5_Chat",
|
|
};
|
|
|
|
/**
|
|
* Resolve the `tone` for a requested model id, or `undefined` when the id is the bare
|
|
* `copilot-m365` / unknown — callers then fall back to the tier default tone. Model-driven
|
|
* tone takes precedence over the tier default (see the executor wiring).
|
|
*/
|
|
export function resolveToneForModel(model: string | undefined): string | undefined {
|
|
if (!model) return undefined;
|
|
return M365_MODEL_TONE_MAP[model];
|
|
}
|
|
|
|
/**
|
|
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
|
|
* 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 {
|
|
type: 4,
|
|
target: "chat",
|
|
invocationId: "0",
|
|
arguments: [
|
|
{
|
|
allowedMessageTypes: opts.allowedMessageTypes
|
|
? [...opts.allowedMessageTypes]
|
|
: [...ALLOWED_MESSAGE_TYPES],
|
|
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: opts.plugins ?? [],
|
|
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
|
|
productThreadType: "Office",
|
|
sessionId: opts.sessionId,
|
|
sliceIds: [],
|
|
source: "officeweb",
|
|
streamingMode: "ConciseWithPadding",
|
|
threadLevelGptId: {},
|
|
tone: opts.tone ?? "magic",
|
|
toolChoice: opts.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 } : {}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Extract the error message from a `type:3` completion frame that carries one
|
|
* (`frame.error.message` / `frame.error`). A clean completion returns null —
|
|
* without this check a server-side invocation error surfaces as a silent empty
|
|
* `stop`, indistinguishable from a genuine empty reply.
|
|
*/
|
|
export function extractCompletionError(frame: Record<string, unknown> | null): string | null {
|
|
if (!frame || frame.type !== 3) return null;
|
|
const error = frame.error;
|
|
if (!error || typeof error !== "object") return null;
|
|
const message = (error as JsonRecord).message;
|
|
return typeof message === "string" && message.length > 0 ? message : JSON.stringify(error);
|
|
}
|
|
|
|
/**
|
|
* True for messages that carry tool/search/code PROGRESS rather than answer text
|
|
* (`messageType:"Progress"`, or the SearchResults/Code/ToolCall content types).
|
|
* Such text must never be folded into the streamed answer.
|
|
*/
|
|
function isToolProgressMessage(m: Record<string, unknown>): boolean {
|
|
if (m.messageType === "Progress") return true;
|
|
const ct = m.contentType;
|
|
return ct === "SearchResults" || ct === "Code" || ct === "ToolCall" || ct === "EarlyProgress";
|
|
}
|
|
|
|
/**
|
|
* True when an update frame is a tool-progress frame — it carries Progress /
|
|
* SearchResults / Code / ToolCall messages alongside (possibly) a `writeAtCursor`
|
|
* increment that belongs to that progress, not to the answer (the browser client
|
|
* suppresses such writeAtCursor deltas; so must we).
|
|
*/
|
|
export function isToolProgressFrame(frame: Record<string, unknown> | null): boolean {
|
|
if (!isUpdateFrame(frame)) return false;
|
|
const args = frame.arguments;
|
|
const first = Array.isArray(args) ? (args[0] as Record<string, unknown> | undefined) : undefined;
|
|
const messages = first?.messages;
|
|
if (!Array.isArray(messages)) return false;
|
|
return messages.some(
|
|
(m) => !!m && typeof m === "object" && isToolProgressMessage(m as Record<string, unknown>)
|
|
);
|
|
}
|
|
|
|
/** 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 (isToolProgressMessage(m)) continue;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Extract an incremental `writeAtCursor` delta from a `type:1` update frame. The EDU /
|
|
* GPT-5.5 path (`OfficeWebIncludedCopilot`, feature.bizchatfluxv3) streams response text
|
|
* as `arguments[0].writeAtCursor` INCREMENTS instead of only accumulated `messages[].text`
|
|
* snapshots. Returns null when the frame carries no writeAtCursor delta. (#6210)
|
|
*/
|
|
export function extractWriteAtCursor(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 wac = first?.writeAtCursor;
|
|
return typeof wac === "string" && wac.length > 0 ? wac : null;
|
|
}
|
|
|
|
/**
|
|
* Extract the final answer from a `type:2` invocation-result frame
|
|
* (`item.result.message`). Used as a last-resort fallback when a turn emitted no
|
|
* streamed content (some EDU turns only surface the answer here). (#6210)
|
|
*/
|
|
export function extractFinalResultMessage(frame: Record<string, unknown> | null): string | null {
|
|
if (!frame || frame.type !== 2) return null;
|
|
const item = frame.item as Record<string, unknown> | undefined;
|
|
const result = item?.result as Record<string, unknown> | undefined;
|
|
const message = result?.message;
|
|
return typeof message === "string" && message.length > 0 ? message : null;
|
|
}
|
|
|
|
/**
|
|
* Fold a single incoming frame into the running bot answer, returning the suffix to
|
|
* stream (`delta`) and the new accumulated text (`next`). Handles both wire formats:
|
|
* `messages[].text` snapshots are the full accumulated answer (diffed via
|
|
* {@link incrementalDelta}), while `writeAtCursor` frames are incremental and are
|
|
* appended. Non-content frames leave the state unchanged. (#6210)
|
|
*/
|
|
export function accumulateBotContent(
|
|
previous: string,
|
|
frame: Record<string, unknown> | null
|
|
): { delta: string; next: string } {
|
|
// A tool-progress frame's writeAtCursor belongs to the progress card (search
|
|
// queries, code interpreter output…), not to the answer text.
|
|
if (isToolProgressFrame(frame)) return { delta: "", next: previous };
|
|
const snapshot = extractBotText(frame);
|
|
if (snapshot) {
|
|
return { delta: incrementalDelta(previous, snapshot), next: snapshot };
|
|
}
|
|
const wac = extractWriteAtCursor(frame);
|
|
if (wac) {
|
|
return { delta: wac, next: previous + wac };
|
|
}
|
|
return { delta: "", next: previous };
|
|
}
|