Compare commits

..

4 Commits

Author SHA1 Message Date
diegosouzapw
4a2a9978bc Merge remote-tracking branch 'origin/release/v3.8.50' into feat/9571-plugin-streaming-usage-timing 2026-08-07 11:52:33 -03:00
diegosouzapw
867fbc8bad fix(changelog): remove YAML frontmatter from 9571 fragment
The changelog fragment format requires the first non-empty line to be
a markdown bullet ("- "). YAML frontmatter was the first non-empty
line, causing the integrity check to fail.
2026-08-07 01:11:58 -03:00
diegosouzapw
d557ea2222 Merge remote-tracking branch 'origin/release/v3.8.50' into babysit/9571-plugin-streaming-usage-timing 2026-08-06 23:58:33 -03:00
diegosouzapw
1bdd1be8c6 feat(plugins): add onStreamComplete built-in event exposing streaming usage and timing (#9571) 2026-08-06 21:40:40 -03:00
20 changed files with 337 additions and 639 deletions

View File

@@ -2483,8 +2483,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# signature, replacing the pinned default key. Required when self-hosting a
# feed signed with a different key pair.
# RADAR_FEED_PUBKEY=
# Threshold for the forgotten-sibling-tests quality gate. When the number of
# changed source files in a PR exceeds this value, the consumer scan is skipped
# (typically for release PRs or mass refactors). Default is 300.
# FORGOTTEN_SIBLING_MAX_CHANGED=300

View File

@@ -587,9 +587,6 @@ jobs:
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
- name: Detect test-masking (weakened assertions)
run: npm run check:test-masking
# Detect forgotten sibling tests (consumers whose test sibling is not in the diff).
- name: Detect forgotten sibling tests
run: npm run check:forgotten-sibling-tests
# Evidence-in-PR-body (Hard Rule #18 mechanized): claims of "tests pass" must carry output.
- name: Require evidence in PR body
run: npm run check:pr-evidence

View File

@@ -1 +0,0 @@
- **feat(ci):** new quality gate `check-forgotten-sibling-tests` detects when a source symbol changes but consumer tests are not in the same diff — preventing the 7 documented "forgotten sibling test" occurrences from PR #9529 ([#9530](https://github.com/diegosouzapw/OmniRoute/issues/9530))

View File

@@ -0,0 +1,11 @@
- **feat(plugins):** add onStreamComplete built-in event exposing streaming usage and timing (#9571)
Adds a new `onStreamComplete` plugin event that fires after an SSE stream is fully
consumed, carrying usage token counts and timing metrics (latency, TTFT). Built-in
events now include `onStreamComplete` as a fire-and-forget lifecycle hook.
Payload: `status`, `usage` (prompt_tokens, completion_tokens, reasoning_tokens,
cache_read_input_tokens, cache_creation_input_tokens), `timing` (latencyMs, ttft),
`model`, `provider`, `errorCode`.
Non-breaking — existing `onResponse` hooks with `{ streamed: true }` remain unchanged.

View File

@@ -1 +0,0 @@
- fix(combo): distinguish pre-dispatch skips from genuine failures to prevent false 503 ALL_ACCOUNTS_INACTIVE (#9630)

View File

@@ -1,10 +0,0 @@
{
"_comment": "Forgotten-sibling-tests allowlist (check-forgotten-sibling-tests.mjs). Each entry exempts a (sourcePath, forgottenSibling) pair. Use when a consumer test legitimately does not need changes (compatible refactor, same interface). Every entry needs a reason with the tracking issue or PR ref. Wildcard sourcePath=\"*\" exempts the sibling regardless of which source triggered it.",
"_schema": [
{
"sourcePath": "src/lib/example.ts",
"forgottenSibling": "tests/unit/lib/example-consumer.test.ts",
"reason": "Issue #1234: compatible interface change only — consumer tests verify contract, not implementation"
}
]
}

View File

@@ -176,12 +176,11 @@ Full i18n validation matrix (one job per locale). Entire job is advisory.
Runs on pull requests only.
| Script | Validates | Blocking |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `check:pr-test-policy` | PRs that change production code in `src/`, `open-sse/`, `electron/`, or `bin/` must include or update tests (Hard Rule #8) | Yes |
| `check:test-masking` | Changed test files do not reduce net assert count or add `assert.ok(true)` tautologies | Yes |
| `check:pr-evidence` | PR body cites test/VPS evidence for the change (mechanizes Hard Rule #18 by grepping PR prose — fragile, see Backlog) | Yes |
| `check:forgotten-sibling-tests` | When a PR changes source file Y, detects if any production consumer Z of Y has a test sibling that is NOT included in the same diff — preventing the forgotten-sibling-test pattern | Yes |
| Script | Validates | Blocking |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------- |
| `check:pr-test-policy` | PRs that change production code in `src/`, `open-sse/`, `electron/`, or `bin/` must include or update tests (Hard Rule #8) | Yes |
| `check:test-masking` | Changed test files do not reduce net assert count or add `assert.ok(true)` tautologies | Yes |
| `check:pr-evidence` | PR body cites test/VPS evidence for the change (mechanizes Hard Rule #18 by grepping PR prose — fragile, see Backlog) | Yes |
### Job: `test-vitest`

View File

@@ -1353,12 +1353,6 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
### Quality gate scripts (CI)
| Variable | Default | Description |
| --- | --- | --- |
| `FORGOTTEN_SIBLING_MAX_CHANGED` | `300` | Threshold for the forgotten-sibling-tests quality gate. When a PR changes more source files than this value, the consumer dependency scan is skipped (release PR or mass refactor). |
### Internal service auth
| Variable | Default | Description |

View File

@@ -245,7 +245,10 @@ import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts
import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts";
import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts";
import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts";
import { runPluginOnResponseHook } from "./chatCore/pluginOnResponse.ts";
import {
runPluginOnResponseHook,
runPluginOnStreamCompleteHook,
} from "./chatCore/pluginOnResponse.ts";
import { scheduleStreamingQuotaShareConsumption } from "./chatCore/streamingQuotaShare.ts";
import { recordStreamingUsageStats } from "./chatCore/streamingUsageStats.ts";
import { recordStreamingCost } from "./chatCore/streamingCost.ts";
@@ -4895,6 +4898,17 @@ export async function handleChatCore({
streamUsage,
log,
});
// Plugin onStreamComplete hook — fire-and-forget, fail-open (#9571)
runPluginOnStreamCompleteHook({
status: normalizedStreamStatus,
usage: streamUsage as Record<string, unknown> | undefined,
ttft,
model,
provider,
errorCode: streamErrorCode,
startTime,
});
};
const streamFailureFinalizers = streamFailure.createStreamFailureFinalizers({

View File

@@ -43,3 +43,57 @@ export async function runPluginOnResponseHook(args: {
/* plugin onResponse optional */
}
}
/**
* Payload passed to plugin onStreamComplete hooks after a streaming response is consumed.
* Carries usage token counts, timing metrics (latency, TTFT), model, provider, and error code.
*/
export type PluginOnStreamCompletePayload = {
status: number;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
reasoning_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
timing?: {
latencyMs: number;
ttft?: number;
};
model?: string;
provider?: string;
errorCode?: string;
};
/**
* Run plugin onStreamComplete hooks — fire-and-forget and fail-open.
* Called inside the onStreamComplete callback (chatCore.ts) where usage and timing data
* converge after an SSE stream is fully consumed.
*/
export async function runPluginOnStreamCompleteHook(args: {
status: number;
usage?: Record<string, unknown>;
ttft?: number;
model: string | null | undefined;
provider: string | null | undefined;
errorCode?: string | null | undefined;
startTime: number;
}): Promise<void> {
try {
const { runOnStreamComplete } = await import("@/lib/plugins/hooks");
runOnStreamComplete({
status: args.status,
usage: args.usage as PluginOnStreamCompletePayload["usage"],
timing: {
latencyMs: Date.now() - args.startTime,
ttft: args.ttft,
},
model: args.model ?? undefined,
provider: args.provider ?? undefined,
errorCode: args.errorCode ?? undefined,
}).catch(() => {});
} catch (_) {
/* plugin onStreamComplete optional */
}
}

View File

@@ -1,13 +0,0 @@
/**
* Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper.
*/
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
export { persistDiscoveredAntigravityProjectId };
export function preferAntigravityConnectionsWithStoredProject(
connections: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
return connections.filter(
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
);
}

View File

@@ -2036,35 +2036,24 @@ export async function handleComboChat({
if (setTry < maxSetRetries) continue;
// All set retries exhausted — return the final error
if (!lastStatus) {
if (recordedAttempts === 0) {
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_TARGETS_SKIPPED",
latencyMs,
fallbackCount,
});
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
buildComboDiag("all_targets_skipped"),
{ code: "ALL_TARGETS_SKIPPED", type: "service_unavailable" }
);
}
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_ACCOUNTS_INACTIVE",
latencyMs,
fallbackCount,
});
recordComboFailure(effectiveSessionId, combo.name);
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all upstream accounts are inactive",
buildComboDiag("all_accounts_inactive"),
{ code: "ALL_ACCOUNTS_INACTIVE", type: "service_unavailable" }
);
}
if (!lastStatus) {
notifyWebhookEvent("request.failed", {
combo: combo.name,
reason: "ALL_ACCOUNTS_INACTIVE",
latencyMs,
fallbackCount,
});
// Silent-stop fix: bump the failure counter so the session pin clears on the 3rd
// consecutive all-inactive cascade; buildRecoveryHint emits `switch-combo` with a
// next-step that points the user at /dashboard/providers.
recordComboFailure(effectiveSessionId, combo.name);
return errorResponseWithComboDiagnostics(
503,
"Service temporarily unavailable: all upstream accounts are inactive",
buildComboDiag("all_accounts_inactive"),
{ code: "ALL_ACCOUNTS_INACTIVE", type: "service_unavailable" }
);
}
const status = lastStatus;
// Build aggregated error message with per-model failure details for diagnostics.
@@ -3015,30 +3004,18 @@ async function handleRoundRobinCombo({
});
}
if (!lastStatus) {
if (recordedAttempts === 0) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
type: "service_unavailable",
code: "ALL_TARGETS_SKIPPED",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All round-robin combo models unavailable";

View File

@@ -230,7 +230,6 @@
"coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
"coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md",
"check:pr-test-policy": "node scripts/check/check-pr-test-policy.mjs",
"check:forgotten-sibling-tests": "node scripts/check/check-forgotten-sibling-tests.mjs",
"coverage:report:legacy": "c8 report --output-dir coverage --exclude=open-sse --reporter=text --reporter=text-summary",
"test:all": "npm run test:unit && npm run test:vitest && npm run test:vitest:ui && npm run test:ecosystem && npm run test:e2e",
"check": "npm run lint && npm run test",

View File

@@ -1,241 +0,0 @@
#!/usr/bin/env node
// scripts/check/check-forgotten-sibling-tests.mjs
// Gate: when a PR changes source file Y, detects if any production consumer Z of Y
// has a test sibling (Z.test.ts or Z/index.ts → Z/test.ts) that is NOT included in
// the same PR diff. Prevents the "forgotten sibling test" pattern (7 occurrences
// fixed in PR #9529).
//
// Usage:
// node scripts/check/check-forgotten-sibling-tests.mjs
//
// Environment (PR context):
// GITHUB_BASE_SHA or GITHUB_BASE_REF — base of the PR diff
// FORGOTTEN_SIBLING_MAX_CHANGED — threshold for release-PR skip (default 300)
//
// No PR context → no-ops (exit 0, no output).
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { globSync } from "tinyglobby";
import { ROOT, IMPORT_RE, EXTS, SRC_ROOTS, resolveImport } from "./lib/importResolution.mjs";
// ─── Constants ────────────────────────────────────────────────────────────────
const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"];
const EXCLUDED_PATTERNS = [
/\/tests\//,
/\/migrations\//,
/\/__tests__\//,
/\.test\./,
/\.spec\./,
/\/node_modules\//,
/\/config\/(?:quality|eslint|tsconfig)/,
];
const DEFAULT_MAX_CHANGED = 300;
// ─── Helpers (exported for testing) ───────────────────────────────────────────
export function runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
export function isSourceFile(filePath) {
if (EXCLUDED_PATTERNS.some((p) => p.test(filePath))) return false;
return (
SOURCE_ROOTS.some((root) => filePath.startsWith(root)) && EXTS.some((e) => filePath.endsWith(e))
);
}
export function resolveBase() {
if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
return null;
}
/**
* Test sibling of a production file Z.
* Convention: Z.ts → Z.test.ts, or Z/index.ts → Z/test.ts.
* Returns the repo-relative path of the test sibling, or null if none.
*/
export function testSiblingOf(relPath) {
const abs = path.join(ROOT, relPath);
const dir = path.dirname(abs);
const base = path.basename(abs).replace(/\.(ts|tsx|mts|js|mjs)$/, "");
const candidates = [
path.join(dir, `${base}.test.ts`),
path.join(dir, `${base}.test.tsx`),
path.join(dir, `${base}.test.mjs`),
];
// Also try Z/test/ subdirectory
const testDir = path.join(dir.replace(/\/?$/, ""), "test");
candidates.push(
path.join(testDir, `${base}.test.ts`),
path.join(testDir, `${base}.test.tsx`),
path.join(testDir, `${base}.test.mjs`)
);
// For Z/index.ts or Z/route.ts, also try Z/test.ts
if (base === "index" || base === "route") {
candidates.push(
path.join(dir, `${base}.test.ts`),
path.join(dir, `${base}.test.tsx`),
path.join(dir, `${base}.test.mjs`),
path.join(dir, "test.ts"),
path.join(dir, "test.tsx"),
path.join(dir, "test.mjs")
);
}
for (const c of candidates) {
if (fs.existsSync(c)) return path.relative(ROOT, c);
}
return null;
}
/**
* For a given changed source file (repo-relative path), find all production
* files under SOURCE_ROOTS that directly import from it. Uses the pre-built
* reverse dependency map.
*/
export function findConsumers(changedRelPath, prodFileMap) {
const abs = path.join(ROOT, changedRelPath);
const consumers = [];
for (const [consumerRel, deps] of Object.entries(prodFileMap)) {
if (deps.has(abs)) consumers.push(consumerRel);
}
return consumers.sort();
}
/**
* Build a map of all production files → their resolved direct import deps (Set of absolute paths).
*/
export function buildProdFileMap() {
const map = {};
const prodFiles = globSync(
SRC_ROOTS.map((r) => `${r}/**/*.{ts,tsx,mts,js,mjs}`),
{ cwd: ROOT, ignore: ["**/node_modules/**", "**/tests/**", "**/__tests__/**"] }
);
for (const f of prodFiles) {
if (!isSourceFile(f)) continue;
const fullPath = path.join(ROOT, f);
let code;
try {
code = fs.readFileSync(fullPath, "utf8");
} catch {
continue;
}
const deps = new Set();
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, fullPath);
if (!r) continue;
deps.add(r);
}
map[f] = deps;
}
return map;
}
/**
* Check if an allowlist entry exempts a (changedFile, consumerWithMissingTest) pair.
*/
export function isAllowlisted(changedFile, missingTest, allowlist) {
for (const entry of allowlist) {
if (entry.sourcePath === changedFile && entry.forgottenSibling === missingTest) return true;
if (entry.sourcePath === "*" && entry.forgottenSibling === missingTest) return true;
}
return false;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const base = resolveBase();
if (!base) {
console.log("[forgotten-sibling] no base ref (not a PR context) — skipping check.");
return;
}
// Read allowlist
let allowlist = [];
try {
const raw = JSON.parse(
fs.readFileSync(path.join(ROOT, "config/quality/forgotten-sibling-allowlist.json"), "utf8")
);
allowlist = Array.isArray(raw) ? raw : [];
} catch {
// No allowlist file or parse error — treat as empty
}
// Get changed files
const changedFiles = runGit(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
const changedSources = changedFiles.filter(isSourceFile);
const changedTestFiles = new Set(
changedFiles.filter((f) => /\.(?:test|spec)\.(?:ts|tsx|mjs)$/.test(f))
);
// Release PR skip: if too many changed files, skip the detailed consumer walk.
const maxChanged = Number(process.env.FORGOTTEN_SIBLING_MAX_CHANGED) || DEFAULT_MAX_CHANGED;
if (maxChanged > 0 && changedSources.length > maxChanged) {
console.log(
`[forgotten-sibling] ${changedSources.length} source file(s) changed exceeds ` +
`threshold (${maxChanged}) — skipping consumer scan (release PR).\n` +
` A diff this large is a release PR or mass refactor; each file already ` +
`passed this gate on its own PR during the cycle.`
);
return;
}
if (changedSources.length === 0) {
console.log("[forgotten-sibling] no changed source files — OK.");
return;
}
// Build the production dependency map (source → consumers)
const prodFileMap = buildProdFileMap();
const flags = [];
for (const changedSource of changedSources) {
const consumers = findConsumers(changedSource, prodFileMap);
if (consumers.length === 0) continue;
for (const consumer of consumers) {
const testSibling = testSiblingOf(consumer);
if (!testSibling) continue;
if (changedTestFiles.has(testSibling)) continue;
if (isAllowlisted(changedSource, testSibling, allowlist)) continue;
flags.push(
`${changedSource}: \`${consumer}\` imports this file and has a test sibling ` +
`(\`${testSibling}\`) that is NOT in the current diff. ` +
"When the public API of the changed source changes, consumer tests " +
"may need updating too."
);
}
}
if (flags.length) {
console.error(
`[forgotten-sibling] ${flags.length} forgotten sibling test(s) detected:\n` +
flags.map((f) => `${f}`).join("\n") +
`\n → Add the missing test files to this PR or add an allowlist entry ` +
`(config/quality/forgotten-sibling-allowlist.json) with a justification.`
);
process.exit(1);
}
console.log(
`[forgotten-sibling] OK — ${changedSources.length} source file(s) changed, ` +
`no forgotten sibling tests.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) main();

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env node
// scripts/check/lib/importResolution.mjs
// Shared import resolution logic extracted from build-test-impact-map.mjs.
// Provides resolveImport(), sourceDepsOf(), IMPORT_RE, EXTS, SRC_ROOTS, ROOT.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
export const SRC_ROOTS = ["src", "open-sse"];
export const IMPORT_RE =
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
export const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
/**
* Resolve an import specifier to an absolute file path.
* Handles `@/` aliases, `@omniroute/open-sse` aliases, and relative paths.
* Returns null for external/npm imports or unresolvable specs.
*/
export function resolveImport(spec, fromFile) {
let base;
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse"))
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
else return null;
for (const e of EXTS) {
if (fs.existsSync(base + e)) return base + e;
}
for (const e of EXTS) {
const idx = path.join(base, "index" + e);
if (fs.existsSync(idx)) return idx;
}
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
}
/**
* Walk the transitive import graph of a file and return all source-relative
* paths it depends on (files under src/ or open-sse/).
*/
export function sourceDepsOf(entry) {
const seen = new Set();
const stack = [entry];
const sources = new Set();
while (stack.length) {
const f = stack.pop();
if (seen.has(f)) continue;
seen.add(f);
let code;
try {
code = fs.readFileSync(f, "utf8");
} catch {
continue;
}
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, f);
if (!r) continue;
const rel = path.relative(ROOT, r);
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
stack.push(r);
}
}
return sources;
}

View File

@@ -1,14 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "tinyglobby";
import {
ROOT,
IMPORT_RE,
EXTS,
SRC_ROOTS,
resolveImport,
sourceDepsOf,
} from "../check/lib/importResolution.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const SRC_ROOTS = ["src", "open-sse"];
const IMPORT_RE =
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
function resolveImport(spec, fromFile) {
let base;
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse"))
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
else return null;
for (const e of EXTS) {
if (fs.existsSync(base + e)) return base + e;
}
for (const e of EXTS) {
const idx = path.join(base, "index" + e);
if (fs.existsSync(idx)) return idx;
}
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
}
function sourceDepsOf(entry) {
const seen = new Set();
const stack = [entry];
const sources = new Set();
while (stack.length) {
const f = stack.pop();
if (seen.has(f)) continue;
seen.add(f);
let code;
try {
code = fs.readFileSync(f, "utf8");
} catch {
continue;
}
for (const m of code.matchAll(IMPORT_RE)) {
const spec = m[1] || m[2] || m[3];
if (!spec) continue;
const r = resolveImport(spec, f);
if (!r) continue;
const rel = path.relative(ROOT, r);
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
stack.push(r);
}
}
return sources;
}
// Mirror EXACTLY the `npm run test:unit` glob — the curated set of node:test files.
// The TIA step runs the selected subset via `node --test`, so it must NOT include
@@ -36,10 +79,7 @@ for (const tf of testFiles) {
}
for (const k of Object.keys(map)) map[k].sort();
const out = path.join(ROOT, "config/quality/test-impact-map.json");
fs.writeFileSync(
out,
JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n"
);
fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n");
console.log(
`test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files`
);

View File

@@ -40,6 +40,7 @@ export const BUILTIN_EVENTS = [
"onActivate",
"onDeactivate",
"onUninstall",
"onStreamComplete",
] as const;
export type BuiltinEvent = (typeof BUILTIN_EVENTS)[number];
@@ -251,6 +252,35 @@ export interface Plugin {
onActivate?: (payload: unknown) => Promise<void> | void;
onDeactivate?: (payload: unknown) => Promise<void> | void;
onUninstall?: (payload: unknown) => Promise<void> | void;
onStreamComplete?: (payload: PluginOnStreamCompletePayload) => Promise<void> | void;
}
// ── onStreamComplete event types ──
export type PluginOnStreamCompletePayload = {
status: number;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
reasoning_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
timing?: {
latencyMs: number;
ttft?: number;
};
model?: string;
provider?: string;
errorCode?: string;
};
/**
* Run onStreamComplete hooks — fire-and-forget notification with usage/timing data.
* Called when an SSE stream is fully consumed and usage/timing data is available.
*/
export async function runOnStreamComplete(payload: PluginOnStreamCompletePayload): Promise<void> {
await emitHook("onStreamComplete", payload);
}
/**

View File

@@ -1,130 +0,0 @@
// tests/unit/build/check-forgotten-sibling-tests.test.mjs
// TDD tests for the forgotten-sibling-tests gate (check-forgotten-sibling-tests.mjs).
import assert from "node:assert";
import { describe, it } from "node:test";
import {
testSiblingOf,
isAllowlisted,
isSourceFile,
resolveBase,
findConsumers,
} from "../../../scripts/check/check-forgotten-sibling-tests.mjs";
import { ROOT } from "../../../scripts/check/lib/importResolution.mjs";
describe("check-forgotten-sibling-tests", () => {
describe("isSourceFile", () => {
it("returns true for src/ .ts files", () => {
assert.strictEqual(isSourceFile("src/lib/foo.ts"), true);
});
it("returns true for open-sse/ files", () => {
assert.strictEqual(isSourceFile("open-sse/services/foo.ts"), true);
});
it("returns true for bin/ files", () => {
assert.strictEqual(isSourceFile("bin/cli.ts"), true);
});
it("returns false for test files under tests/", () => {
assert.strictEqual(isSourceFile("tests/unit/foo.test.ts"), false);
});
it("returns false for migration files", () => {
assert.strictEqual(isSourceFile("src/lib/db/migrations/001.sql"), false);
});
it("returns false for node_modules", () => {
assert.strictEqual(isSourceFile("node_modules/foo/index.ts"), false);
});
it("returns false for markdown files", () => {
assert.strictEqual(isSourceFile("docs/readme.md"), false);
});
});
describe("resolveBase", () => {
it("returns GITHUB_BASE_SHA when set", () => {
const prev = process.env.GITHUB_BASE_SHA;
process.env.GITHUB_BASE_SHA = "abc123";
assert.strictEqual(resolveBase(), "abc123");
process.env.GITHUB_BASE_SHA = prev;
});
it("returns origin/REF when only GITHUB_BASE_REF is set", () => {
const prev = process.env.GITHUB_BASE_REF;
delete process.env.GITHUB_BASE_SHA;
process.env.GITHUB_BASE_REF = "release/v3.8.50";
assert.strictEqual(resolveBase(), "origin/release/v3.8.50");
process.env.GITHUB_BASE_REF = prev;
});
it("returns null when neither env var is set", () => {
const prevSha = process.env.GITHUB_BASE_SHA;
const prevRef = process.env.GITHUB_BASE_REF;
delete process.env.GITHUB_BASE_SHA;
delete process.env.GITHUB_BASE_REF;
assert.strictEqual(resolveBase(), null);
process.env.GITHUB_BASE_SHA = prevSha;
process.env.GITHUB_BASE_REF = prevRef;
});
});
describe("testSiblingOf", () => {
it("returns null for a file that has no test sibling", () => {
const result = testSiblingOf("src/lib/db/core.ts");
assert.strictEqual(result, null);
});
});
describe("isAllowlisted", () => {
const allowlist = [
{
sourcePath: "src/lib/example.ts",
forgottenSibling: "tests/unit/lib/consumer.test.ts",
reason: "test",
},
{
sourcePath: "*",
forgottenSibling: "tests/unit/lib/wildcard.test.ts",
reason: "wildcard test",
},
];
it("returns true for exact match", () => {
assert.strictEqual(
isAllowlisted("src/lib/example.ts", "tests/unit/lib/consumer.test.ts", allowlist),
true
);
});
it("returns true for wildcard sourcePath", () => {
assert.strictEqual(
isAllowlisted("src/lib/other.ts", "tests/unit/lib/wildcard.test.ts", allowlist),
true
);
});
it("returns false for non-allowlisted pair", () => {
assert.strictEqual(
isAllowlisted("src/lib/example.ts", "tests/unit/lib/other.test.ts", allowlist),
false
);
});
it("returns false for empty allowlist", () => {
assert.strictEqual(
isAllowlisted("src/lib/example.ts", "tests/unit/lib/consumer.test.ts", []),
false
);
});
});
describe("findConsumers", () => {
it("returns consumers that import the given file", () => {
const absPath = ROOT + "/src/lib/target.ts";
const prodFileMap = {
"src/lib/consumer.ts": new Set([absPath]),
"src/lib/unrelated.ts": new Set(["/other/path.ts"]),
};
const result = findConsumers("src/lib/target.ts", prodFileMap);
assert.deepStrictEqual(result, ["src/lib/consumer.ts"]);
});
it("returns empty array when no consumers import the file", () => {
const prodFileMap = {
"src/lib/consumer.ts": new Set([ROOT + "/src/lib/other.ts"]),
};
const result = findConsumers("src/lib/target.ts", prodFileMap);
assert.deepStrictEqual(result, []);
});
});
});

View File

@@ -7,9 +7,8 @@ import { test, after } from "node:test";
import assert from "node:assert/strict";
const { registerHook, unregisterHook } = await import("../../src/lib/plugins/hooks.ts");
const { runPluginOnResponseHook } = await import(
"../../open-sse/handlers/chatCore/pluginOnResponse.ts"
);
const { runPluginOnResponseHook, runPluginOnStreamCompleteHook } =
await import("../../open-sse/handlers/chatCore/pluginOnResponse.ts");
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
@@ -20,6 +19,7 @@ async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
after(() => {
unregisterHook("onResponse", "test-onresponse-plugin");
unregisterHook("onStreamComplete", "test-onstreamcomplete-plugin");
});
test("no registered hooks → resolves without throwing (no-op)", async () => {
@@ -101,3 +101,140 @@ test("a throwing hook never rejects the caller (fail-open)", async () => {
);
await new Promise((r) => setTimeout(r, 30));
});
// ── onStreamComplete hook tests (#9571) ──
test("onStreamComplete: no registered hooks resolves without throwing (no-op)", async () => {
const start = Date.now();
await assert.doesNotReject(
runPluginOnStreamCompleteHook({
status: 200,
usage: { prompt_tokens: 10, completion_tokens: 20 },
ttft: 150,
model: "gpt-4",
provider: "openai",
errorCode: undefined,
startTime: start - 500,
})
);
});
test("onStreamComplete: registered hook receives usage + timing payload", async () => {
let captured: Record<string, unknown> | undefined;
registerHook(
"onStreamComplete",
"test-onstreamcomplete-plugin",
async (payload: Record<string, unknown>) => {
captured = payload;
}
);
const startTime = Date.now() - 500;
await runPluginOnStreamCompleteHook({
status: 200,
usage: { prompt_tokens: 42, completion_tokens: 100, reasoning_tokens: 5 },
ttft: 200,
model: "claude-3-opus",
provider: "anthropic",
errorCode: undefined,
startTime,
});
await waitFor(() => captured !== undefined);
assert.ok(captured, "expected onStreamComplete hook to be invoked");
// payload shape: status, usage, timing, model, provider
assert.equal(captured!.status, 200);
assert.ok(captured!.usage, "usage should be present");
assert.equal((captured!.usage as Record<string, number>).prompt_tokens, 42);
assert.equal((captured!.usage as Record<string, number>).completion_tokens, 100);
assert.equal((captured!.usage as Record<string, number>).reasoning_tokens, 5);
assert.ok(captured!.timing, "timing should be present");
const timing = captured!.timing as Record<string, number>;
assert.equal(timing.ttft, 200);
assert.ok(timing.latencyMs > 450, "latencyMs should be near 500");
assert.equal(captured!.model, "claude-3-opus");
assert.equal(captured!.provider, "anthropic");
assert.equal(captured!.errorCode, undefined);
});
test("onStreamComplete: payload includes cache token fields when present", async () => {
let captured: Record<string, unknown> | undefined;
registerHook(
"onStreamComplete",
"test-onstreamcomplete-plugin",
async (payload: Record<string, unknown>) => {
captured = payload;
}
);
await runPluginOnStreamCompleteHook({
status: 200,
usage: {
prompt_tokens: 50,
completion_tokens: 30,
cache_read_input_tokens: 20,
cache_creation_input_tokens: 10,
},
ttft: 100,
model: "gpt-4",
provider: "openai",
errorCode: undefined,
startTime: Date.now(),
});
await waitFor(() => captured !== undefined);
assert.ok(captured);
const usage = captured!.usage as Record<string, number>;
assert.equal(usage.cache_read_input_tokens, 20);
assert.equal(usage.cache_creation_input_tokens, 10);
});
test("onStreamComplete: throwing hook never rejects the caller (fail-open)", async () => {
registerHook("onStreamComplete", "test-onstreamcomplete-plugin", async () => {
throw new Error("stream-complete-boom");
});
await assert.doesNotReject(
runPluginOnStreamCompleteHook({
status: 500,
usage: undefined,
ttft: undefined,
model: "gpt-4",
provider: "openai",
errorCode: "upstream_error",
startTime: Date.now(),
})
);
await new Promise((r) => setTimeout(r, 30));
});
test("onStreamComplete: errorCode is passed through when provided", async () => {
let captured: Record<string, unknown> | undefined;
registerHook(
"onStreamComplete",
"test-onstreamcomplete-plugin",
async (payload: Record<string, unknown>) => {
captured = payload;
}
);
await runPluginOnStreamCompleteHook({
status: 502,
usage: undefined,
ttft: undefined,
model: "grok-3",
provider: "xai",
errorCode: "upstream_timeout",
startTime: Date.now(),
});
await waitFor(() => captured !== undefined);
assert.ok(captured);
assert.equal(captured!.status, 502);
assert.equal(captured!.errorCode, "upstream_timeout");
assert.equal(captured!.model, "grok-3");
assert.equal(captured!.provider, "xai");
});

View File

@@ -1,86 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
ComboLogger,
ComboRelayOptions,
} from "../../open-sse/services/combo/types.ts";
import {
handleComboChat,
} from "../../open-sse/services/combo.ts";
import { getCircuitBreaker, STATE } from "../../src/shared/utils/circuitBreaker.js";
const noopLogger: ComboLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} };
type BodyType = Record<string, unknown>;
function okResponse() {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
test("#9630: combo returns 503 when circuit breaker is OPEN but other healthy targets exist", async () => {
const cb = getCircuitBreaker("openai");
cb.state = STATE.OPEN;
cb.resetTimeout = 60000;
cb.failureCount = 5;
cb.failureThreshold = 3;
cb.lastFailureTime = Date.now();
const result = await handleComboChat({
body: { messages: [{ role: "user", content: "hello" }] },
combo: {
name: "repro-9630",
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async (_body: BodyType, modelStr: string) => {
assert.equal(modelStr, "anthropic/claude-opus-5", "should skip openai breaker and try anthropic");
return okResponse();
},
isModelAvailable: async () => true,
log: noopLogger,
settings: null,
relayOptions: null as unknown as ComboRelayOptions,
allCombos: null,
});
assert.ok(result.ok, "should succeed via anthropic fallback when openai breaker is open");
});
test("#9630: combo returns truthful error, not false ALL_ACCOUNTS_INACTIVE, when ALL targets are breaker-open", async () => {
const cb = getCircuitBreaker("openai");
cb.state = STATE.OPEN;
cb.resetTimeout = 60000;
cb.failureCount = 5;
cb.failureThreshold = 3;
cb.lastFailureTime = Date.now();
const cb2 = getCircuitBreaker("anthropic");
cb2.state = STATE.OPEN;
cb2.resetTimeout = 60000;
cb2.failureCount = 5;
cb2.failureThreshold = 3;
cb2.lastFailureTime = Date.now();
const result = await handleComboChat({
body: { messages: [{ role: "user", content: "hello" }] },
combo: {
name: "repro-9630-all-breaker",
strategy: "priority",
models: ["openai/gpt-4", "anthropic/claude-opus-5"],
},
handleSingleModel: async () => { throw new Error("should not be called"); },
isModelAvailable: async () => true,
log: noopLogger,
settings: null,
relayOptions: null as unknown as ComboRelayOptions,
allCombos: null,
});
assert.equal(result.status, 503);
const body = await result.json();
// The diagnostic should NOT claim ALL_ACCOUNTS_INACTIVE when no real dispatch was attempted
assert.notEqual(body.error?.code, "ALL_ACCOUNTS_INACTIVE",
"should not claim ALL_ACCOUNTS_INACTIVE when all targets were gated by pre-dispatch checks");
});