From 28cc89c66cebf31878fcceb27beb1e3d624037de Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 5 Jul 2026 00:09:54 -0300 Subject: [PATCH] fix(providers): copilot-m365-web EDU tier + writeAtCursor streaming (#6210) --- CHANGELOG.md | 1 + open-sse/executors/copilot-m365-connection.ts | 39 ++++- open-sse/executors/copilot-m365-frames.ts | 49 ++++++ open-sse/executors/copilot-m365-web.ts | 28 ++-- ...opilot-m365-edu-writeatcursor-6210.test.ts | 152 ++++++++++++++++++ 5 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d040512c5e..22090e205f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - **fix(services):** 9Router embed panel no longer 404s (optional catch-all route) and the supervisor probes the port before spawning to avoid raw EADDRINUSE ([#6205](https://github.com/diegosouzapw/OmniRoute/issues/6205)). Regression guards: `tests/unit/ninerouter-embed-port-6205.test.ts`, `tests/unit/services/ServiceSupervisor.test.ts`. (thanks @jonlwheat2-gif) - **fix(nodejs):** the default app log path now resolves under `DATA_DIR` (`~/.omniroute/logs/application/app.log`) instead of `process.cwd()` ([#6197](https://github.com/diegosouzapw/OmniRoute/issues/6197)) — the globally-installed CLI runs from an arbitrary working directory, so anchoring the default to cwd made file logging silently write to (or no-op under) an unrelated directory, contradicting the documented `.env.example` default. `getAppLogFilePath()` now computes the default lazily via the pure `resolveDataDir()` resolver (honours a per-process `DATA_DIR`, no directory-creation side effect); an explicit `APP_LOG_FILE_PATH` still wins. Regression guard: `tests/unit/logenv-datadir-path-6197.test.ts` (3). - **fix(docker):** AgentBridge/`startMitm` no longer aborts in containers/headless when the Antigravity-default DNS step can't write `/etc/hosts` ([#6127](https://github.com/diegosouzapw/OmniRoute/issues/6127)), and the privileged command's stderr now reaches `app.log` instead of only a bare exit code hitting the toast ([#6198](https://github.com/diegosouzapw/OmniRoute/issues/6198)). The default DNS step (`addDNSEntry`) was called unguarded while cert install and the two sibling DNS steps were each best-effort — in the runtime Docker image (`USER node`, no `sudo`, read-only `/etc/hosts`) it threw `Command failed with code 1` out of `startMitmInternal` and killed the whole start, discarding the stderr. The three DNS steps are extracted into a best-effort `provisionDnsEntries()` where each failure is logged with the full `err` (stderr included, folded in by `systemCommands.ts`) and never aborts the start. Regression guard: `tests/unit/mitm-dns-graceful-degrade-6127.test.ts` (4). +- **fix(providers):** copilot-m365-web now supports the M365 Education "Starter / OfficeWebIncludedCopilot" tier and no longer returns an empty `content:null` stream ([#6210](https://github.com/diegosouzapw/OmniRoute/issues/6210)). Two gaps: (1) `buildWsUrl()` hardcoded the individual-consumer scenario (`OfficeWebPaidConsumerCopilot`, `isEdu=false`) — the EDU tier is now opt-in via `providerSpecificData.tier="edu"`, emitting `scenario=OfficeWebIncludedCopilot`/`isEdu=true` (the individual path is unchanged); (2) the EDU/GPT-5.5 path streams deltas via `arguments[0].writeAtCursor` (incremental) instead of only `messages[].text` (accumulated snapshots), which the parser dropped — a new `accumulateBotContent()` folds both formats, with `type:2 item.result.message` as a last-resort fallback. Regression guard: `tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts` (10). (thanks @qpeyba) - **fix(providers):** GitLab Duo executor now feeds tool results back into the prompt instead of looping ([#6220](https://github.com/diegosouzapw/OmniRoute/issues/6220)) — `buildPrompt()` branched only on `system`/`user` and took `userParts.at(-1)`, silently dropping the `assistant{tool_calls}` + `tool{result}` turns the client appended, so the reconstructed prompt was byte-identical to turn 1 and the model re-emitted the same `` call forever. When a tool exchange is present the full conversation is now serialized, folding each tool result back keyed by its `tool_call_id`; simple conversations keep the legacy shape. Complements the tool_call emission from [#6051](https://github.com/diegosouzapw/OmniRoute/issues/6051) (the `kilo-duplicate` label was a false positive — different, sequential defect). Regression guard: `tests/unit/gitlab-tool-result-feedback-6220.test.ts` (4). - **fix(providers):** opencode-go/opencode-zen can now synthesize the OpenCode CLI identity headers Cloudflare requires on VPS egress ([#5997](https://github.com/diegosouzapw/OmniRoute/issues/5997)) — on a datacenter VPS, `opencode.ai/zen/go/v1/chat/completions` 403s (HTML challenge) requests lacking CLI identity, while the reporter's control curl proved that `User-Agent: opencode-cli/1.0.0` + `x-opencode-client: cli` + `x-opencode-project: default` + fresh request/session UUIDs succeed. Opt-in via `OPENCODE_SYNTHESIZE_CLI_HEADERS=true` (values overridable via `OPENCODE_GO_USER_AGENT`/`OPENCODE_USER_AGENT`/`OPENCODE_CLIENT`/`OPENCODE_PROJECT`); it fills only headers the client did not already send. Kept **off by default** — the forward-only path is deliberate (fabricating a wrong value risks upstream rejection; a prior dedup regressed with `opencode/local`), so this replaces the fragile local header-injection shim without changing default behavior. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (6). (thanks @aleksesipenko) diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts index e5545de3f9..48f4ff5944 100644 --- a/open-sse/executors/copilot-m365-connection.ts +++ b/open-sse/executors/copilot-m365-connection.ts @@ -24,6 +24,17 @@ export const M365_INDIVIDUAL_DEFAULTS = { scenario: "OfficeWebPaidConsumerCopilot", } as const; +/** + * Education "Starter / OfficeWebIncludedCopilot" tier overrides, captured from the + * official UI in #6210. Differs from the individual tier only by scenario + isEdu; + * opt-in via `providerSpecificData.tier="edu"` so the individual path is unchanged. + */ +export const M365_EDU_OVERRIDES = { + scenario: "OfficeWebIncludedCopilot", + isEdu: "true", + licenseType: "Starter", +} as const; + export const M365_DEFAULT_VARIANTS = [ "EnableMcpServerWidgets", "feature.EnableMcpServerWidgets", @@ -79,6 +90,10 @@ export interface M365ConnectionParams { chathubPath: string; // "@" accessToken: string; variants?: string; + /** Tier overrides — when unset, buildWsUrl falls back to the individual defaults. */ + scenario?: string; + isEdu?: string; + licenseType?: string; } /** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */ @@ -154,7 +169,23 @@ export function resolveConnectionParams( } const host = (typeof psd.host === "string" && psd.host) || M365_INDIVIDUAL_DEFAULTS.host; const variants = typeof psd.variants === "string" && psd.variants ? psd.variants : undefined; - return { host, chathubPath, accessToken, variants }; + + // Tier selection (opt-in). tier="edu"|"included" applies the EDU overrides; individual + // fields can also be overridden directly via providerSpecificData. (#6210) + const tier = typeof psd.tier === "string" ? psd.tier.toLowerCase() : ""; + const isEduTier = tier === "edu" || tier === "included"; + const scenario = + (typeof psd.scenario === "string" && psd.scenario) || + (isEduTier ? M365_EDU_OVERRIDES.scenario : undefined); + const isEdu = + (typeof psd.isEdu === "string" && psd.isEdu) || + (typeof psd.isEdu === "boolean" && String(psd.isEdu)) || + (isEduTier ? M365_EDU_OVERRIDES.isEdu : undefined); + const licenseType = + (typeof psd.licenseType === "string" && psd.licenseType) || + (isEduTier ? M365_EDU_OVERRIDES.licenseType : undefined); + + return { host, chathubPath, accessToken, variants, scenario, isEdu, licenseType }; } /** @@ -175,10 +206,10 @@ export function buildWsUrl(params: M365ConnectionParams): string { source: M365_INDIVIDUAL_DEFAULTS.source, product: M365_INDIVIDUAL_DEFAULTS.product, agentHost: M365_INDIVIDUAL_DEFAULTS.agentHost, - licenseType: M365_INDIVIDUAL_DEFAULTS.licenseType, - isEdu: "false", + licenseType: params.licenseType ?? M365_INDIVIDUAL_DEFAULTS.licenseType, + isEdu: params.isEdu ?? "false", agent: M365_INDIVIDUAL_DEFAULTS.agent, - scenario: M365_INDIVIDUAL_DEFAULTS.scenario, + scenario: params.scenario ?? M365_INDIVIDUAL_DEFAULTS.scenario, }); return `wss://${params.host}/m365Copilot/Chathub/${params.chathubPath}?${query.toString()}`; } diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts index 267b897c51..d4544aea20 100644 --- a/open-sse/executors/copilot-m365-frames.ts +++ b/open-sse/executors/copilot-m365-frames.ts @@ -228,3 +228,52 @@ export function incrementalDelta(previous: string, next: string): string { 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 | null): string | null { + if (!isUpdateFrame(frame)) return null; + const args = (frame as Record).arguments; + const first = Array.isArray(args) ? (args[0] as Record | 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 | null): string | null { + if (!frame || frame.type !== 2) return null; + const item = frame.item as Record | undefined; + const result = item?.result as Record | 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 | null +): { delta: string; next: string } { + 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 }; +} diff --git a/open-sse/executors/copilot-m365-web.ts b/open-sse/executors/copilot-m365-web.ts index 7b7470c4a2..890e3bf9f0 100644 --- a/open-sse/executors/copilot-m365-web.ts +++ b/open-sse/executors/copilot-m365-web.ts @@ -9,12 +9,12 @@ import { resolveConnectionParams, } from "./copilot-m365-connection.ts"; import { + accumulateBotContent, buildChatInvocation, encodeFrame, - extractBotText, + extractFinalResultMessage, handshakeError, handshakeFrame, - incrementalDelta, isCompletionFrame, keepaliveFrame, parseFrame, @@ -68,6 +68,7 @@ export class CopilotM365WebExecutor extends BaseExecutor { let settled = false; let buffer = ""; let previousText = ""; + let finalResultMessage = ""; let handshakeComplete = false; const cleanup = () => { @@ -85,6 +86,13 @@ export class CopilotM365WebExecutor extends BaseExecutor { if (settled) return; settled = true; cleanup(); + // Last-resort fallback (#6210): some EDU turns surface the answer only in the + // type:2 invocation result. Emit it if nothing was streamed. + if (!previousText && finalResultMessage) { + controller.enqueue( + encoder.encode(sseChunk(input.model, { content: finalResultMessage })) + ); + } controller.enqueue(encoder.encode(sseChunk(input.model, {}, "stop"))); controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); @@ -157,13 +165,15 @@ export class CopilotM365WebExecutor extends BaseExecutor { continue; } - const text = extractBotText(frame); - if (text) { - const delta = incrementalDelta(previousText, text); - previousText = text; - if (delta) { - controller.enqueue(encoder.encode(sseChunk(input.model, { content: delta }))); - } + const { delta, next } = accumulateBotContent(previousText, frame); + previousText = next; + if (delta) { + controller.enqueue(encoder.encode(sseChunk(input.model, { content: delta }))); + } + + const finalMsg = extractFinalResultMessage(frame); + if (finalMsg) { + finalResultMessage = finalMsg; } if (isCompletionFrame(frame)) { diff --git a/tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts b/tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts new file mode 100644 index 0000000000..d1db3deac3 --- /dev/null +++ b/tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts @@ -0,0 +1,152 @@ +/** + * Regression test for #6210 — copilot-m365-web empty response on the M365 Education + * "Starter / OfficeWebIncludedCopilot" tier. + * + * Two independent gaps produced a `200 OK` with `content:null`: + * + * 1. Tier config: `buildWsUrl()` hardcoded the individual-consumer scenario + * (`OfficeWebPaidConsumerCopilot`, `isEdu=false`). The EDU tier the reporter captured + * from the official UI needs `scenario=OfficeWebIncludedCopilot`, `isEdu=true`. Now + * opt-in via `providerSpecificData.tier="edu"` so the individual path is unchanged. + * + * 2. Frame parsing: the EDU / GPT-5.5 path streams deltas via `arguments[0].writeAtCursor` + * (incremental) instead of only `arguments[0].messages[].text` (accumulated snapshot). + * `extractBotText()` returned null for those frames, so nothing was emitted. The final + * `type:2 item.result.message` is now also honored as a last-resort fallback. + * + * The live round-trip against a real M365 EDU tenant is the separate Rule #18 validation; + * these frame captures come verbatim from the reporter. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + extractWriteAtCursor, + extractFinalResultMessage, + accumulateBotContent, +} from "../../open-sse/executors/copilot-m365-frames.ts"; +import { + buildWsUrl, + resolveConnectionParams, + M365_INDIVIDUAL_DEFAULTS, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Part 2: writeAtCursor + type:2 fallback ───────────────────────────────── + +test("extractWriteAtCursor: reads arguments[0].writeAtCursor delta [#6210]", () => { + const frame = { type: 1, target: "update", arguments: [{ writeAtCursor: " received", references: {} }] }; + assert.equal(extractWriteAtCursor(frame), " received"); +}); + +test("extractWriteAtCursor: null when absent or not an update frame [#6210]", () => { + assert.equal(extractWriteAtCursor({ type: 1, target: "update", arguments: [{ messages: [] }] }), null); + assert.equal(extractWriteAtCursor({ type: 2, item: {} }), null); + assert.equal(extractWriteAtCursor(null), null); +}); + +test("extractFinalResultMessage: reads type:2 item.result.message [#6210]", () => { + const frame = { + type: 2, + invocationId: "0", + item: { turnState: "Completed", result: { value: "Success", message: "Test received — everything's working." } }, + }; + assert.equal(extractFinalResultMessage(frame), "Test received — everything's working."); + assert.equal(extractFinalResultMessage({ type: 1, target: "update", arguments: [] }), null); +}); + +test("accumulateBotContent: reproduces the reporter's EDU frame sequence → full answer, not null [#6210]", () => { + const frames = [ + { type: 1, target: "update", arguments: [{ messages: [{ text: "Test", author: "bot" }] }] }, + { type: 1, target: "update", arguments: [{ writeAtCursor: " received", references: {} }] }, + { type: 1, target: "update", arguments: [{ writeAtCursor: " — everything's working.", references: {} }] }, + { + type: 1, + target: "update", + arguments: [{ messages: [{ text: "Test received — everything's working.", author: "bot" }], isLastUpdate: true }], + }, + ]; + + let previous = ""; + let emitted = ""; + for (const frame of frames) { + const { delta, next } = accumulateBotContent(previous, frame); + previous = next; + emitted += delta; + } + + assert.equal(previous, "Test received — everything's working."); + assert.equal(emitted, "Test received — everything's working."); +}); + +test("accumulateBotContent: writeAtCursor before any snapshot still accumulates [#6210]", () => { + let previous = ""; + const seq = [ + { type: 1, target: "update", arguments: [{ writeAtCursor: "Hello" }] }, + { type: 1, target: "update", arguments: [{ writeAtCursor: " world" }] }, + ]; + let emitted = ""; + for (const frame of seq) { + const { delta, next } = accumulateBotContent(previous, frame); + previous = next; + emitted += delta; + } + assert.equal(previous, "Hello world"); + assert.equal(emitted, "Hello world"); +}); + +test("accumulateBotContent: non-content frame yields empty delta, unchanged state [#6210]", () => { + const { delta, next } = accumulateBotContent("prev", { type: 3, invocationId: "0" }); + assert.equal(delta, ""); + assert.equal(next, "prev"); +}); + +// ── Part 1: EDU tier config (opt-in) ──────────────────────────────────────── + +const BASE_PARAMS = { + host: "substrate.office.com", + chathubPath: "user-oid@tenant-id", + accessToken: "tok", +}; + +test("buildWsUrl: individual (default) tier is unchanged — OfficeWebPaidConsumerCopilot/isEdu=false [#6210]", () => { + const url = new URL(buildWsUrl(BASE_PARAMS)); + assert.equal(url.searchParams.get("scenario"), M365_INDIVIDUAL_DEFAULTS.scenario); + assert.equal(url.searchParams.get("isEdu"), "false"); +}); + +test("buildWsUrl: EDU tier emits OfficeWebIncludedCopilot + isEdu=true + Starter license [#6210]", () => { + const url = new URL( + buildWsUrl({ + ...BASE_PARAMS, + scenario: "OfficeWebIncludedCopilot", + isEdu: "true", + licenseType: "Starter", + }) + ); + assert.equal(url.searchParams.get("scenario"), "OfficeWebIncludedCopilot"); + assert.equal(url.searchParams.get("isEdu"), "true"); + assert.equal(url.searchParams.get("licenseType"), "Starter"); +}); + +test("resolveConnectionParams: tier='edu' in providerSpecificData selects the EDU scenario [#6210]", () => { + const params = resolveConnectionParams({ + apiKey: "access_token=tok", + providerSpecificData: { chathubPath: "user@tenant", tier: "edu" }, + } as never); + assert.ok(!("error" in params), "should resolve without error"); + if (!("error" in params)) { + assert.equal(params.scenario, "OfficeWebIncludedCopilot"); + assert.equal(params.isEdu, "true"); + } +}); + +test("resolveConnectionParams: no tier keeps the individual defaults [#6210]", () => { + const params = resolveConnectionParams({ + apiKey: "access_token=tok", + providerSpecificData: { chathubPath: "user@tenant" }, + } as never); + assert.ok(!("error" in params)); + if (!("error" in params)) { + // scenario/isEdu unset → buildWsUrl falls back to individual defaults. + assert.equal(params.scenario, undefined); + } +});