fix(providers): copilot-m365-web EDU tier + writeAtCursor streaming (#6210)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 00:09:54 -03:00
parent 0d0a933520
commit 28cc89c66c
5 changed files with 256 additions and 13 deletions

View File

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

View File

@@ -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; // "<user-oid>@<tenant-id>"
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()}`;
}

View File

@@ -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<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 } {
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 };
}

View File

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

View File

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