mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 11:52:26 +03:00
Resynced onto release/v3.8.51 (originally targeted main; retargeted since the default branch is release/v3.8.51). One real conflict in open-sse/handlers/chatCore.ts, but it was entirely unrelated to this PR's actual purpose: the antigravity-aware lockExactModel branching and deferAntigravityQuotaStateToCaller state exist on main but haven't been synced to release/v3.8.51 yet (confirmed by diffing your branch against its own main merge-base — the only change there was a Prettier reformat, not new logic). Discarded that unrelated drift and kept the release tip's current quota-lock shape; the onStreamComplete plugin wiring itself is untouched and intact. typecheck:core clean, 13/13 plugin delivery tests pass. Thanks for the thorough three-layer root-cause writeup.
152 lines
6.3 KiB
TypeScript
152 lines
6.3 KiB
TypeScript
// Regression test for #11825 — the `onStreamComplete` plugin event is emitted
|
|
// internally (chatCore.ts → runPluginOnStreamCompleteHook → runOnStreamComplete →
|
|
// emitHook) but never DELIVERED to disk-installed plugins, because the plugin-facing
|
|
// wiring is missing at three layers:
|
|
// 1. src/lib/plugins/manifest.ts — HooksSchema has no `onStreamComplete` field, so
|
|
// `hooks.onStreamComplete: true` in plugin.json is silently stripped by Zod.
|
|
// 2. src/lib/plugins/loader.ts — loadPlugin() never builds an IPC wrapper for it.
|
|
// 3. src/lib/plugins/manager.ts — activate()'s hardcoded `hookNames` list omits it,
|
|
// so even a wired handler is never registerHook()'d into the registry.
|
|
// The pre-existing green tests (tests/unit/chatcore-plugin-onresponse.test.ts) call
|
|
// `registerHook("onStreamComplete", ...)` directly — a path real plugins cannot use — so
|
|
// they never exercised this delivery gap. This test drives the *real* plugin path:
|
|
// install → activate → emit, and asserts a plugin process actually receives the payload.
|
|
//
|
|
// Secondary gap (#11825): the payload must carry a `requestId` so a consumer can join the
|
|
// stream-completion event to the originating request. We assert the plugin sees it.
|
|
import { test, describe, beforeEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const { validateManifest } = await import("../../src/lib/plugins/manifest.ts");
|
|
const mgr = await import("../../src/lib/plugins/manager.ts");
|
|
const db = await import("../../src/lib/db/plugins.ts");
|
|
const { getDbInstance } = await import("../../src/lib/db/core.ts");
|
|
const { runPluginOnStreamCompleteHook } =
|
|
await import("../../open-sse/handlers/chatCore/pluginOnResponse.ts");
|
|
|
|
async function waitFor(pred: () => boolean, timeoutMs = 6000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline && !pred()) {
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
}
|
|
|
|
// ── Gap 1: manifest schema must preserve the flag ──────────────────────────────
|
|
|
|
test("#11825 manifest: HooksSchema preserves hooks.onStreamComplete", () => {
|
|
const m = validateManifest({
|
|
name: "sc-manifest",
|
|
version: "1.0.0",
|
|
hooks: { onResponse: true, onStreamComplete: true },
|
|
});
|
|
// On the buggy code Zod strips the unknown key and applyDefaults never sets it, so this
|
|
// is `undefined` instead of `true` — plugin.json can't opt into the event at all.
|
|
assert.equal(
|
|
m.hooks.onStreamComplete,
|
|
true,
|
|
"manifest.hooks.onStreamComplete must survive validation so plugin.json can subscribe"
|
|
);
|
|
});
|
|
|
|
// ── Gaps 2 + 3 + requestId: real end-to-end delivery to a plugin process ────────
|
|
|
|
describe("#11825 onStreamComplete is delivered to an installed+activated plugin", () => {
|
|
const NAME = "sc-delivery-11825";
|
|
const outFile = join(tmpdir(), `omniroute-onstreamcomplete-11825-${process.pid}.json`);
|
|
|
|
beforeEach(() => {
|
|
getDbInstance(); // ensure the plugins table migration has run
|
|
try {
|
|
db.deletePlugin(NAME);
|
|
} catch {}
|
|
try {
|
|
rmSync(outFile, { force: true });
|
|
} catch {}
|
|
});
|
|
|
|
test("plugin's onStreamComplete handler fires with usage/timing and a requestId", async (t) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "sc-11825-"));
|
|
const pluginDir = join(tmp, NAME);
|
|
mkdirSync(pluginDir, { recursive: true });
|
|
writeFileSync(
|
|
join(pluginDir, "plugin.json"),
|
|
JSON.stringify({
|
|
name: NAME,
|
|
version: "1.0.0",
|
|
main: "index.js",
|
|
hooks: { onStreamComplete: true },
|
|
})
|
|
);
|
|
// The plugin runs in an isolated child process, so it reports what it received by
|
|
// writing the payload to a file whose path is baked into its source.
|
|
writeFileSync(
|
|
join(pluginDir, "index.js"),
|
|
`const fs = require("fs");
|
|
const OUT = ${JSON.stringify(outFile)};
|
|
module.exports = {
|
|
onStreamComplete: async (payload) => { fs.writeFileSync(OUT, JSON.stringify(payload)); },
|
|
};
|
|
`
|
|
);
|
|
|
|
t.after(async () => {
|
|
await mgr.pluginManager.deactivate(NAME).catch(() => {});
|
|
try {
|
|
db.deletePlugin(NAME);
|
|
} catch {}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(outFile, { force: true });
|
|
});
|
|
|
|
await mgr.pluginManager.install(pluginDir);
|
|
await mgr.pluginManager.activate(NAME);
|
|
|
|
// Sanity: the DB should record the declared hook (also broken on the buggy path).
|
|
const row = db.getPluginByName(NAME);
|
|
assert.ok(row, "plugin should be installed");
|
|
assert.ok(
|
|
(row!.hooks as unknown as string[]).includes("onStreamComplete"),
|
|
`installed plugin should declare onStreamComplete; got ${JSON.stringify(row!.hooks)}`
|
|
);
|
|
|
|
// Drive the REAL producer entry point used by chatCore.ts.
|
|
const startTime = Date.now() - 500;
|
|
await runPluginOnStreamCompleteHook({
|
|
status: 200,
|
|
usage: { prompt_tokens: 11, completion_tokens: 22, reasoning_tokens: 3 },
|
|
ttft: 120,
|
|
model: "claude-3-opus",
|
|
provider: "anthropic",
|
|
errorCode: undefined,
|
|
startTime,
|
|
requestId: "trace-11825",
|
|
});
|
|
|
|
await waitFor(() => existsSync(outFile));
|
|
assert.ok(
|
|
existsSync(outFile),
|
|
"the plugin's onStreamComplete handler never ran — the event was dropped into an " +
|
|
"empty registry (no manifest/loader/manager wiring reaches disk-installed plugins)"
|
|
);
|
|
|
|
const payload = JSON.parse(readFileSync(outFile, "utf-8")) as Record<string, unknown>;
|
|
assert.equal(payload.status, 200);
|
|
assert.equal((payload.usage as Record<string, number>).prompt_tokens, 11);
|
|
assert.equal((payload.usage as Record<string, number>).completion_tokens, 22);
|
|
assert.ok(payload.timing, "timing should be present");
|
|
assert.equal((payload.timing as Record<string, number>).ttft, 120);
|
|
assert.ok((payload.timing as Record<string, number>).latencyMs >= 450);
|
|
assert.equal(payload.model, "claude-3-opus");
|
|
assert.equal(payload.provider, "anthropic");
|
|
// Secondary gap: the event must be correlatable to the originating request.
|
|
assert.equal(
|
|
payload.requestId,
|
|
"trace-11825",
|
|
"onStreamComplete payload must carry requestId so it can be joined to the request"
|
|
);
|
|
});
|
|
});
|