mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
fix(providers): copilot-m365-web EDU tier + writeAtCursor streaming (#6210)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
152
tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts
Normal file
152
tests/unit/copilot-m365-edu-writeatcursor-6210.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user