From 6cd4d38e2107c4ee7b73de017de01101b71fd1a8 Mon Sep 17 00:00:00 2001 From: N123 Project Date: Sat, 22 Aug 2026 08:32:19 +0700 Subject: [PATCH] fix(m365): BizChat invocation shape drift + HAR-import UX + Antigravity alias note (#11069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⭐5 — M365 Copilot (BizChat) individual/consumer path — 3 itens: (1) forma de invocação do #10718 derivou de novo (2026-08-21 capture): optionsSets 14→34, allowedMessageTypes 6→30, tone "magic"→"Magic", plugins []→[{BingWebSearch}], disconnectBehavior em todos os tiers, +8 keys de clientInfo; verificado contra conta real com round-trip WebSocket (ping-then-close → resposta real). (2) Aviso sobre o alias Antigravity gemini-3.1-pro-high ainda não publicado (3.8.49 pré-data). (3) Botão "Import .har file" no modal de credencial M365. Conflito resolvido em copilot-m365-frames.ts (board vs release tip): mantive o forwarding de opts.plugins/toolChoice/customInstructions do HEAD com os NOVOS defaults da captura (BingWebSearch builtin, tone "Magic"). Alinhei 3 testes pré-existentes que afirmavam o contrato antigo (m365-bizchat-frames-4042 clientInfo, m365-tone-model-variants tone, copilot-m365-tool-calls plugins) — propagação de contrato, não mascaramento. Rebaselinei AddApiKeyModal 1073→1080 (crescimento próprio da parte 3, ~Har import button) com anotação. Validação: typecheck limpo, 142/142 testes m365/copilot verdes, changelog-integrit/file-size/eslint OK. --- config/quality/file-size-baseline.json | 5 +- open-sse/executors/copilot-m365-frames.ts | 135 ++++++++++---- .../combos/IntelligentComboPanel.tsx | 23 +-- src/app/(dashboard)/dashboard/combos/page.tsx | 7 +- .../[id]/components/HarImportButton.tsx | 165 ++++++++++++++++++ .../[id]/components/modals/AddApiKeyModal.tsx | 21 ++- .../components/modals/EditConnectionModal.tsx | 7 + src/i18n/messages/en.json | 14 ++ src/shared/utils/m365HarImport.ts | 116 ++++++++++++ ...ot-m365-enterprise-invocation-7870.test.ts | 54 ++++-- ...ilot-m365-invocation-refresh-10718.test.ts | 79 +++++++-- tests/unit/copilot-m365-tool-calls.test.ts | 4 +- tests/unit/dashboard/m365-har-import.test.ts | 78 +++++++++ tests/unit/m365-bizchat-frames-4042.test.ts | 17 +- tests/unit/m365-tone-model-variants.test.ts | 11 +- 15 files changed, 644 insertions(+), 92 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx create mode 100644 src/shared/utils/m365HarImport.ts create mode 100644 tests/unit/dashboard/m365-har-import.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index da8b6c3449..536318240a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -443,7 +443,7 @@ "src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/constants/providers/apikey/gateways.ts": 1250 }, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1073, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/constants/providers/apikey/gateways.ts": 1298, @@ -459,7 +459,8 @@ "open-sse/executors/commandCode.ts": 1059, "_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).", "_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.", - "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)." + "_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).", + "_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts." }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index 7260afebd7..f4172b1190 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const; 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. + * Allowed message types observed in a 2026-08-21 live capture of a working + * `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a + * non-ping SSE event" on every individual/consumer M365 Copilot call). The + * #10718 6-entry shape above no longer produces a `type:1 target:"update"` + * frame at all — the socket only replies with SignalR keepalive pings and then + * closes, which is exactly what surfaces client-side as that generic stream + * error. 30 entries, up from 6. */ export const ALLOWED_MESSAGE_TYPES = [ "Chat", "Suggestion", + "InternalSearchQuery", "Disengaged", - "Progress", - "EndOfRequest", "InternalLoaderMessage", + "Progress", + "GeneratedCode", + "RenderCardRequest", + "AdsQuery", + "SemanticSerp", + "GenerateContentQuery", + "GenerateGraphicArt", + "SearchQuery", + "ConfirmationCard", + "AuthError", + "DeveloperLogs", + "TriggerPlugin", + "HintInvocation", + "MemoryUpdate", + "EndOfRequest", + "TriggerConfirmation", + "ResumeInvokeAction", + "ResumeUserInputRequest", + "TriggerUserInputRequest", + "EscapeHatch", + "TriggerPluginAuth", + "ResumePluginAuth", + "SideBySide", + "ReferencesListComplete", + "SwitchRespondingEndpoint", ] as const; /** @@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [ ] 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. + * Individual / EDU option sets from a 2026-08-21 live capture — 34 entries, up + * from the #10718 14-entry shape (which itself superseded an earlier 25-entry + * shape). Each recapture so far has been additive/reshuffled rather than a + * wholesale replacement — treat this as the protocol continuing to drift, not + * a one-time fix; a future capture may again need to update this list. */ 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", + "cwc_code_interpreter", + "cwc_code_interpreter_amsfix", "cwcfluxgptv", "flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch", "gptvnorm2048", + "cwc_code_interpreter_citation_fix", + "code_interpreter_interactive_charts", + "cwc_code_interpreter_interactive_charts_inline_image", + "code_interpreter_matplotlib_patching", "cwc_fileupload_odb", "update_memory_plugin", "add_custom_instructions", @@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [ "flux_v3_progress_messages", "enable_batch_token_processing", "enable_gg_gpt", + "async_client_interaction", + "flux_v3_references", + "flux_v3_references_entities", + "flux_v3_references_ci", + "add_filestore_filetype", + "cwc_code_interpreter_citation_sourceannotations", + "cdxcwc_code_interpreter_hallucinated_url_filter", + "flux_v3_image_gen_enable_dimensions", + "flux_v3_image_gen_enable_non_watermarked_storage", + "flux_v3_image_gen_enable_icon_dimensions", + "flux_v3_image_gen_enable_system_text_with_params", + "flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts", + "flux_v3_image_gen_enable_story", + "rich_responses", ] as const; /** Append the record separator to a JSON-serializable frame. */ @@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): { } 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", + // 2026-08-21 capture — the individual/consumer surface now sends "Magic" + // (capitalized), matching the enterprise tone literal. The #10718 + // lowercase "magic" is part of the shape that gets silently dropped. + tone: "Magic", allowedMessageTypes: ALLOWED_MESSAGE_TYPES, - // Omitted entirely on the individual/EDU wire (see ChatInvocationOptions). - disconnectBehavior: undefined, + // 2026-08-21 capture — disconnectBehavior:"continue" is now present on the + // individual/consumer wire too, not just enterprise (see ChatInvocationOptions). + disconnectBehavior: "continue", }; } @@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin /** * 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. + * Base shape from the #10718 recapture (populated `clientInfo` + + * `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a + * rich `message` object), extended per a 2026-08-21 live capture that found the + * #10718 shape alone no longer produces a `type:1 target:"update"` frame — the + * socket only replies with keepalive pings and closes. The additions below + * (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`, + * `isSbsSupported`, `renderReferencesBehindEOS`, + * `message.connectedFederatedConnections`, and `disconnectBehavior` on every + * tier) are exactly the fields the 2026-08-21 capture had that this shape was + * missing; the #10718 fields (`conversationId`, `productThreadType`, + * `toolChoice`, `message.attachments`) are kept as-is since removing them was + * not verified against a live socket. */ export function buildChatInvocation(opts: ChatInvocationOptions): Record { + const clientInfo = { + clientAppName: "Office", + clientPlatform: "mcmcopilot-web", + clientEntrypoint: "mcmcopilot-officeweb", + clientSessionId: opts.sessionId, + ProductCategory: "Chat", + clientAppType: "Web", + productEntryPoint: "ChatPanel", + deviceOS: "Windows", + deviceType: "Desktop", + clientPlatformVersion: "10", + }; + return { type: 4, target: "chat", @@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record +) { + if (typeof t?.has === "function" && t.has(key)) return t(key, values); return fallback; } @@ -94,10 +99,9 @@ export default function IntelligentComboPanel({ const updatedCombo = await response.json(); onComboUpdated?.(updatedCombo); notify.success( - getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace( - "{pack}", - modePackId - ) + getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", { + pack: modePackId, + }).replace("{pack}", modePackId) ); } catch (error: any) { notify.error(error?.message || "Failed to update mode pack."); @@ -184,10 +188,9 @@ export default function IntelligentComboPanel({ {savingModePack && ( - {getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace( - "{pack}", - savingModePack - )} + {getI18nOrFallback(t, "savingModePack", "Saving {pack}…", { + pack: savingModePack, + }).replace("{pack}", savingModePack)} )} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index fceada4eb5..7cb1c6c0ee 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) { return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; } -function getI18nOrFallback(t, key, fallback) { +function getI18nOrFallback(t, key, fallback, values) { try { - if (typeof t.has === "function" && t.has(key)) return t(key); + if (typeof t.has === "function" && t.has(key)) return t(key, values); } catch {} return fallback; } @@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) { {getI18nOrFallback( t, "recommendationsUpdated", - "Recommendations updated for {strategy}." + "Recommendations updated for {strategy}.", + { strategy: strategyLabel } ).replace("{strategy}", strategyLabel)} )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx new file mode 100644 index 0000000000..0f4a3d3551 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/HarImportButton.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + extractM365CredentialFromHar, + describeHarImportExpiry, + type M365HarImportResult, +} from "@/shared/utils/m365HarImport"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; + +type HarImporter = (text: string) => M365HarImportResult; + +// One entry per web-session provider that can offer HAR import. Add a new +// key here (and its own extractor in src/shared/utils/) to support another +// provider — the button renders nothing for any provider not listed. +const HAR_IMPORTERS: Record = { + "copilot-m365-web": extractM365CredentialFromHar, +}; + +const ERROR_MESSAGE_KEYS: Record = { + notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"], + noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."], + noChathubUrl: [ + "harImportErrorNoChathubUrl", + "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.", + ], + unparsableUrl: [ + "harImportErrorUnparsableUrl", + "Found the chat connection, but couldn't read its URL.", + ], + missingFields: [ + "harImportErrorMissingFields", + "Found the chat connection, but the token was missing from it.", + ], +}; + +export interface HarImportButtonProps { + provider: string; + onImport: (apiKey: string) => void; +} + +export default function HarImportButton({ provider, onImport }: HarImportButtonProps) { + const t = useTranslations("providers") as ProviderMessageTranslator; + const importer = HAR_IMPORTERS[provider]; + const fileInputRef = useRef(null); + const [state, setState] = useState< + | { phase: "idle" } + | { phase: "reading" } + | { phase: "error"; message: string } + | { phase: "success"; expiresAt: number | null } + >({ phase: "idle" }); + + if (!importer) return null; + + async function handleFile(file: File | undefined) { + if (!file) return; + setState({ phase: "reading" }); + let text: string; + try { + text = await file.text(); + } catch { + setState({ + phase: "error", + message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."), + }); + return; + } + + const result = importer(text); + if (!result.ok) { + const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [ + "harImportErrorUnknown", + "Couldn't extract a credential from that HAR file.", + ]; + setState({ phase: "error", message: providerText(t, key, fallback) }); + return; + } + + onImport(result.apiKey); + setState({ phase: "success", expiresAt: result.expiresAt }); + } + + const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null; + const expiryText = + expiry?.tone === "unknown" + ? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.") + : expiry?.tone === "bad" + ? providerText( + t, + "harImportStatusExpired", + "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.", + { minutes: Math.abs(expiry.minutesRemaining ?? 0) } + ) + : expiry?.tone === "warn" + ? providerText( + t, + "harImportStatusExpiringSoon", + "Imported — valid for only ~{minutes}m more.", + { minutes: expiry.minutesRemaining ?? 0 } + ) + : expiry?.tone === "ok" + ? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", { + minutes: expiry.minutesRemaining ?? 0, + }) + : null; + + return ( +
+
+ + + {providerText( + t, + "harImportButtonHint", + "Export from DevTools Network tab after sending at least one chat message." + )} + + { + void handleFile(event.target.files?.[0]); + event.target.value = ""; + }} + /> +
+ {state.phase === "error" && ( +

+ {state.message} +

+ )} + {state.phase === "success" && expiryText && ( +

+ {expiryText} +

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 71dc8456f7..75917aec57 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -31,6 +31,7 @@ import { import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"; import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; +import HarImportButton from "../HarImportButton"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; import { buildAddProviderSpecificData } from "./connectionProviderSpecificData"; import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase"; @@ -209,13 +210,13 @@ export default function AddApiKeyModal({ ? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester." : isWebSessionCredential ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) - : isLocalSelfHostedProvider - ? t("localProviderApiKeyOptionalHint", { - provider: localProviderMetadata?.name || providerName || provider || "", - }) - : apiKeyOptional - ? t("apiKeyOptionalHint") - : undefined; + : isLocalSelfHostedProvider + ? t("localProviderApiKeyOptionalHint", { + provider: localProviderMetadata?.name || providerName || provider || "", + }) + : apiKeyOptional + ? t("apiKeyOptionalHint") + : undefined; const credentialValidationFailedMessage = isWebSessionCredential ? providerText( t, @@ -750,6 +751,12 @@ export default function AddApiKeyModal({ t={t} /> )} + {provider && ( + setFormData({ ...formData, apiKey })} + /> + )} {!isNoAuthWebSessionCredential && (
)} + {provider && ( + setFormData({ ...formData, apiKey })} + /> + )} {!isNoAuthWebSessionCredential && (
@?... + * access_token=...`) — the same URL the executor itself connects to (see + * `open-sse/executors/copilot-m365-connection.ts`). No network calls here; + * everything operates on the HAR text already in the browser. + */ + +/** Prefix identifying the BizChat ChatHub WebSocket request in a HAR entry. */ +export const M365_CHATHUB_WS_PREFIX = "wss://substrate.office.com/m365Copilot/Chathub/"; + +export type M365HarImportResult = + | { ok: true; apiKey: string; chathubPath: string; expiresAt: number | null } + | { ok: false; error: string }; + +interface HarEntryLike { + request?: { url?: unknown }; +} + +interface HarLike { + log?: { entries?: unknown }; +} + +/** Decode a JWT payload WITHOUT verification — exp is a display hint only. */ +function decodeJwtExpiry(token: string): number | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const json = JSON.parse(atob(padded)) as { exp?: unknown }; + return typeof json.exp === "number" ? json.exp * 1000 : null; + } catch { + return null; + } +} + +/** + * Extract the `copilot-m365-web` credential from raw HAR file text. + * + * Scans every request in `log.entries` for the ChatHub WebSocket URL and + * uses the LAST match (a HAR may contain several turns of the same session; + * the most recent one carries the freshest token). Returns a structured + * error — never throws — so callers can render it directly. + */ +export function extractM365CredentialFromHar(text: string): M365HarImportResult { + let har: HarLike; + try { + har = JSON.parse(text) as HarLike; + } catch { + return { ok: false, error: "notJson" }; + } + + const entries = har.log?.entries; + if (!Array.isArray(entries)) { + return { ok: false, error: "noEntries" }; + } + + let matchedUrl: string | null = null; + for (const entry of entries as HarEntryLike[]) { + const url = entry?.request?.url; + if (typeof url === "string" && url.startsWith(M365_CHATHUB_WS_PREFIX)) { + matchedUrl = url; + } + } + + if (!matchedUrl) { + return { ok: false, error: "noChathubUrl" }; + } + + let parsed: URL; + try { + parsed = new URL(matchedUrl); + } catch { + return { ok: false, error: "unparsableUrl" }; + } + + const token = parsed.searchParams.get("access_token"); + const chathubPath = decodeURIComponent(parsed.pathname.split("/m365Copilot/Chathub/")[1] || ""); + + if (!token || !chathubPath) { + return { ok: false, error: "missingFields" }; + } + + return { + ok: true, + apiKey: `access_token=${token}; chathubPath=${chathubPath}`, + chathubPath, + expiresAt: decodeJwtExpiry(token), + }; +} + +export type HarImportExpiryTone = "ok" | "warn" | "bad" | "unknown"; + +export interface HarImportExpiryStatus { + tone: HarImportExpiryTone; + minutesRemaining: number | null; +} + +/** Classify a decoded token expiry for the inline status hint. */ +export function describeHarImportExpiry( + expiresAt: number | null, + now: number = Date.now() +): HarImportExpiryStatus { + if (expiresAt === null) return { tone: "unknown", minutesRemaining: null }; + const minutesRemaining = Math.round((expiresAt - now) / 60000); + if (minutesRemaining <= 0) return { tone: "bad", minutesRemaining }; + if (minutesRemaining < 15) return { tone: "warn", minutesRemaining }; + return { tone: "ok", minutesRemaining }; +} diff --git a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts index 98a3548dfe..971180f19d 100644 --- a/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts +++ b/tests/unit/copilot-m365-enterprise-invocation-7870.test.ts @@ -15,7 +15,10 @@ class MockM365WebSocket { closed = false; listeners = new Map(); - constructor(public url: string, public options: unknown) { + constructor( + public url: string, + public options: unknown + ) { MockM365WebSocket.instances.push(this); queueMicrotask(() => this.emit("open")); } @@ -144,30 +147,55 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () = assert.equal(invocationArgs.tone, "Magic"); }); -test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => { +test("2026-08-21: individual (no tier) chat invocation carries the recaptured shape", async () => { const invocationArgs = await sendChatInvocation(undefined); const optionsSets = invocationArgs.optionsSets as string[]; - // The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from - // the wire — the stale set was part of the silently-dropped shape. + // The pre-#10718 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is + // still gone from the wire — only the 2026-08-21 34-entry set is current. assert.ok(!optionsSets.includes("enable_msa_user")); assert.ok(optionsSets.includes("enable_gg_gpt")); - // The browser sends tone:"magic" (lowercase) on the individual/EDU surface. - assert.equal(invocationArgs.tone, "magic"); + // The individual/consumer surface now sends tone:"Magic" (capitalized) — + // the #10718 lowercase "magic" is stale. + assert.equal(invocationArgs.tone, "Magic"); assert.deepEqual(invocationArgs.allowedMessageTypes, [ "Chat", "Suggestion", + "InternalSearchQuery", "Disengaged", - "Progress", - "EndOfRequest", "InternalLoaderMessage", + "Progress", + "GeneratedCode", + "RenderCardRequest", + "AdsQuery", + "SemanticSerp", + "GenerateContentQuery", + "GenerateGraphicArt", + "SearchQuery", + "ConfirmationCard", + "AuthError", + "DeveloperLogs", + "TriggerPlugin", + "HintInvocation", + "MemoryUpdate", + "EndOfRequest", + "TriggerConfirmation", + "ResumeInvokeAction", + "ResumeUserInputRequest", + "TriggerUserInputRequest", + "EscapeHatch", + "TriggerPluginAuth", + "ResumePluginAuth", + "SideBySide", + "ReferencesListComplete", + "SwitchRespondingEndpoint", ]); }); -test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => { +test("2026-08-21: EDU-tier chat invocation carries the same recaptured shape", async () => { const invocationArgs = await sendChatInvocation("edu"); const optionsSets = invocationArgs.optionsSets as string[]; assert.ok(!optionsSets.includes("enable_msa_user")); - assert.equal(invocationArgs.tone, "magic"); + assert.equal(invocationArgs.tone, "Magic"); }); test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => { @@ -179,11 +207,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin ); }); -test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => { +test("2026-08-21: individual (no tier) chat invocation also sends disconnectBehavior=continue (now on every tier)", async () => { const invocationArgs = await sendChatInvocation(undefined); assert.equal( invocationArgs.disconnectBehavior, - undefined, - `individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` + "continue", + `individual-tier invocation must carry disconnectBehavior="continue"; got ${JSON.stringify(invocationArgs.disconnectBehavior)}` ); }); diff --git a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts index f5e602ce3d..415dc5036b 100644 --- a/tests/unit/copilot-m365-invocation-refresh-10718.test.ts +++ b/tests/unit/copilot-m365-invocation-refresh-10718.test.ts @@ -41,7 +41,7 @@ test("#10718: metricsFrame emits the exact bytes observed in the browser capture // ── Recaptured invocation shape ──────────────────────────────────────────── -test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => { +test("2026-08-21: buildChatInvocation matches the recaptured arguments[0] key set", () => { const arg = buildChatInvocation({ text: "Say OK in one word.", traceId: "11111111-1111-1111-1111-111111111111", @@ -50,19 +50,25 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", conversationId: "44444444-4444-4444-4444-444444444444", }).arguments[0] as Record; - // Exact key set from the capture — additions AND omissions are both pinned, - // because the stale keys are exactly what got the shape dropped. + // Exact key set from the 2026-08-21 capture (issue: individual/consumer M365 + // Copilot calls got only SignalR keepalive pings and no type:1 update at all + // — "Stream ended before producing a non-ping SSE event" client-side). The + // #10718 shape below is missing exactly the keys this capture added. assert.deepEqual(Object.keys(arg).sort(), [ "allowedMessageTypes", "clientCorrelationId", "clientInfo", "conversationId", + "disconnectBehavior", + "extraExtensionParameters", + "isSbsSupported", "isStartOfSession", "message", "options", "optionsSets", "plugins", "productThreadType", + "renderReferencesBehindEOS", "sessionId", "sliceIds", "source", @@ -73,13 +79,32 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", "traceId", ]); assert.equal(arg.productThreadType, "Office"); - assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" }); + assert.deepEqual(arg.clientInfo, { + clientAppName: "Office", + clientPlatform: "mcmcopilot-web", + clientEntrypoint: "mcmcopilot-officeweb", + clientSessionId: "22222222-2222-2222-2222-222222222222", + ProductCategory: "Chat", + clientAppType: "Web", + productEntryPoint: "ChatPanel", + deviceOS: "Windows", + deviceType: "Desktop", + clientPlatformVersion: "10", + }); assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444"); assert.equal(arg.toolChoice, null); - assert.equal(arg.tone, "magic"); + // The individual/consumer surface now sends "Magic" (capitalized), matching + // the enterprise tone literal — the #10718 lowercase "magic" is stale. + assert.equal(arg.tone, "Magic"); + assert.equal(arg.isSbsSupported, true); + assert.equal(arg.renderReferencesBehindEOS, true); + assert.deepEqual(arg.extraExtensionParameters, {}); + assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]); + // Sent on every tier now, not gated to enterprise as #8971 described. + assert.equal(arg.disconnectBehavior, "continue"); }); -test("#10718: the message object carries the recaptured rich shape", () => { +test("2026-08-21: the message object carries the recaptured rich shape", () => { const arg = buildChatInvocation({ text: "Say OK in one word.", traceId: "t", @@ -93,7 +118,9 @@ test("#10718: the message object carries the recaptured rich shape", () => { "adaptiveCards", "attachments", "author", + "clientInfo", "clientPreferences", + "connectedFederatedConnections", "entityAnnotationTypes", "experienceType", "inputMethod", @@ -107,26 +134,41 @@ test("#10718: the message object carries the recaptured rich shape", () => { assert.equal(message.messageType, "Chat"); assert.equal(message.requestId, "r"); assert.equal(message.experienceType, "Default"); - assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]); + assert.deepEqual(message.entityAnnotationTypes, [ + "People", + "File", + "Event", + "Email", + "TeamsMessage", + ]); assert.equal(message.attachments, null); assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 }); + assert.deepEqual(message.connectedFederatedConnections, ["dummyId"]); + // Same clientInfo object echoed inside message, per the capture. + assert.deepEqual(message.clientInfo, arg.clientInfo); }); -test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => { +test("2026-08-21: default tier lists are the recaptured 34-entry optionsSets / 30-entry allowedMessageTypes", () => { const overrides = resolveChatInvocationOverrides(undefined); - assert.equal(overrides.optionsSets.length, 14); - assert.equal(overrides.allowedMessageTypes.length, 6); - assert.equal(overrides.tone, "magic"); - // The pre-#10718 consumer/MSA flags are gone from the wire. + assert.equal(overrides.optionsSets.length, 34); + assert.equal(overrides.allowedMessageTypes.length, 30); + assert.equal(overrides.tone, "Magic"); + assert.equal(overrides.disconnectBehavior, "continue"); const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[]; const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[]; - for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) { - assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`); + // Entries the 2026-08-21 capture showed that the #10718 lists lacked. + for (const present of [ + "cwc_code_interpreter", + "rich_responses", + "async_client_interaction", + "flux_v3_references", + ]) { + assert.ok(optionSets.includes(present), `${present} must be in the default option sets`); } - for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) { - assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`); + for (const present of ["InternalSearchQuery", "GeneratedCode", "AuthError", "TriggerPlugin"]) { + assert.ok(messageTypes.includes(present), `${present} must be in allowedMessageTypes`); } - // Entries the capture showed and the old lists lacked. + // Entries the #10718 capture showed and this capture still confirms. assert.ok(optionSets.includes("cwcfluxgptv")); assert.ok(messageTypes.includes("EndOfRequest")); }); @@ -134,8 +176,7 @@ test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-ent // ── refresh_token helpers ────────────────────────────────────────────────── function fakeJwt(claims: Record): string { - const b64 = (value: unknown) => - Buffer.from(JSON.stringify(value)).toString("base64url"); + const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); return `${b64({ alg: "none" })}.${b64(claims)}.sig`; } diff --git a/tests/unit/copilot-m365-tool-calls.test.ts b/tests/unit/copilot-m365-tool-calls.test.ts index 679cdc5224..cf35483cec 100644 --- a/tests/unit/copilot-m365-tool-calls.test.ts +++ b/tests/unit/copilot-m365-tool-calls.test.ts @@ -182,7 +182,9 @@ test("buildChatInvocation defaults stay backward compatible", () => { requestId: "r", conversationId: "c", }).arguments[0] as Record; - assert.deepEqual(arg.plugins, []); + // 2026-08-21 capture (#11069): BingWebSearch BuiltIn plugin is now the + // default on all tiers (was [] in the #10718 shape); toolChoice stays null. + assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]); assert.equal(arg.toolChoice, null); assert.equal(arg.customInstructions, undefined); }); diff --git a/tests/unit/dashboard/m365-har-import.test.ts b/tests/unit/dashboard/m365-har-import.test.ts new file mode 100644 index 0000000000..63515c69e7 --- /dev/null +++ b/tests/unit/dashboard/m365-har-import.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractM365CredentialFromHar, + describeHarImportExpiry, + M365_CHATHUB_WS_PREFIX, +} from "../../../src/shared/utils/m365HarImport.ts"; + +function fakeJwt(exp: number): string { + const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${b64({ alg: "none" })}.${b64({ exp })}.sig`; +} + +function harWithUrl(url: string | null): string { + return JSON.stringify({ + log: { entries: url ? [{ request: { url } }] : [] }, + }); +} + +test("extracts access_token + chathubPath from a matching ChatHub WS entry", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const token = fakeJwt(exp); + const url = `${M365_CHATHUB_WS_PREFIX}oid-123%40tenant-456?chatsessionid=abc&access_token=${token}`; + const result = extractM365CredentialFromHar(harWithUrl(url)); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.chathubPath, "oid-123@tenant-456"); + assert.equal(result.apiKey, `access_token=${token}; chathubPath=oid-123@tenant-456`); + assert.ok(result.expiresAt !== null && Math.abs(result.expiresAt - exp * 1000) < 1000); +}); + +test("picks the LAST matching entry when a HAR has multiple turns", () => { + const oldToken = fakeJwt(1000); + const freshToken = fakeJwt(9999999999); + const har = JSON.stringify({ + log: { + entries: [ + { request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${oldToken}` } }, + { request: { url: "https://unrelated.example/" } }, + { request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${freshToken}` } }, + ], + }, + }); + const result = extractM365CredentialFromHar(har); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.ok(result.apiKey.includes(freshToken)); +}); + +test("returns notJson for malformed input", () => { + const result = extractM365CredentialFromHar("not json{{{"); + assert.deepEqual(result, { ok: false, error: "notJson" }); +}); + +test("returns noEntries when log.entries is missing/not an array", () => { + const result = extractM365CredentialFromHar(JSON.stringify({ log: {} })); + assert.deepEqual(result, { ok: false, error: "noEntries" }); +}); + +test("returns noChathubUrl when no request matches the ChatHub prefix", () => { + const result = extractM365CredentialFromHar(harWithUrl("https://example.com/")); + assert.deepEqual(result, { ok: false, error: "noChathubUrl" }); +}); + +test("returns missingFields when the URL matches but lacks access_token or chathubPath", () => { + const result = extractM365CredentialFromHar(harWithUrl(`${M365_CHATHUB_WS_PREFIX}`)); + assert.equal(result.ok, false); +}); + +test("describeHarImportExpiry classifies ok/warn/bad/unknown", () => { + const now = Date.now(); + assert.equal(describeHarImportExpiry(now + 30 * 60000, now).tone, "ok"); + assert.equal(describeHarImportExpiry(now + 5 * 60000, now).tone, "warn"); + assert.equal(describeHarImportExpiry(now - 60000, now).tone, "bad"); + assert.equal(describeHarImportExpiry(null, now).tone, "unknown"); +}); diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts index cd67a0e42b..f89fae406e 100644 --- a/tests/unit/m365-bizchat-frames-4042.test.ts +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -131,7 +131,22 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te assert.equal(arg.sessionId, "session-id"); assert.equal(arg.conversationId, "conversation-id"); assert.equal(arg.productThreadType, "Office"); - assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" }); + // 2026-08-21 capture (#11069): clientInfo gained 8 keys (clientEntrypoint, + // clientSessionId, ProductCategory, clientAppType, productEntryPoint, + // deviceOS, deviceType, clientPlatformVersion). The #10718 base shape only + // carried clientAppName + clientPlatform. + assert.deepEqual(arg.clientInfo, { + clientAppName: "Office", + clientPlatform: "mcmcopilot-web", + clientEntrypoint: "mcmcopilot-officeweb", + clientSessionId: "session-id", + ProductCategory: "Chat", + clientAppType: "Web", + productEntryPoint: "ChatPanel", + deviceOS: "Windows", + deviceType: "Desktop", + clientPlatformVersion: "10", + }); assert.equal(arg.isStartOfSession, true); assert.ok(Array.isArray(arg.optionsSets)); assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt")); diff --git a/tests/unit/m365-tone-model-variants.test.ts b/tests/unit/m365-tone-model-variants.test.ts index 8860c17cdc..5152dd8431 100644 --- a/tests/unit/m365-tone-model-variants.test.ts +++ b/tests/unit/m365-tone-model-variants.test.ts @@ -30,10 +30,11 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone" const enterprise = resolveChatInvocationOverrides("enterprise"); const individual = resolveChatInvocationOverrides(undefined); - // enterprise tier default tone is Magic; individual/EDU sends "magic" (#10718 - // recapture — the old "" default was part of the silently-dropped shape) + // enterprise tier default tone is Magic; individual/EDU now also sends + // "Magic" (capitalized) — the 2026-08-21 capture (#11069) showed lowercase + // "magic" is part of the shape that gets silently dropped (ping-only socket). assert.equal(enterprise.tone, "Magic"); - assert.equal(individual.tone, "magic"); + assert.equal(individual.tone, "Magic"); // precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring) const toneFor = (model: string | undefined, tierTone: string) => @@ -43,9 +44,9 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone" assert.equal(toneFor("copilot-m365-claude-opus", enterprise.tone), "Claude_Opus"); assert.equal(toneFor("copilot-m365-claude-opus", individual.tone), "Claude_Opus"); - // the bare id keeps whatever the tier resolved + // the bare id keeps whatever the tier resolved (both "Magic" post-#11069) assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic"); - assert.equal(toneFor("copilot-m365", individual.tone), "magic"); + assert.equal(toneFor("copilot-m365", individual.tone), "Magic"); }); test("registry exposes the bare id (first) plus every tone variant", () => {