port: sync from Mike/pygienium@4e56b46d
This commit is contained in:
37
.githooks/pre-commit
Executable file
37
.githooks/pre-commit
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# pre-commit — typecheck the generated omp port before committing.
|
||||
#
|
||||
# Mirrors the CI port job (.gitea/workflows/port-to-omp.yml). The source
|
||||
# repo's own tsconfig extends the host harness tsconfig and is not
|
||||
# self-contained, so the reliable typecheck target is the regenerated port:
|
||||
# it ships a self-contained tsconfig and the pinned @oh-my-pi SDK as a real
|
||||
# devDependency. Regenerating into a temp dir and running `tsc --noEmit`
|
||||
# there catches exactly what CI will fail on (e.g. an interface field added
|
||||
# without updating its object literals).
|
||||
#
|
||||
# Enable (per clone): git config core.hooksPath .githooks
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Fast path: no TypeScript-adjacent change staged -> nothing to typecheck.
|
||||
if git diff --cached --quiet -- src/ agents/ package.json tsconfig.json port-to-omp.mjs; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
LOG="$(mktemp)"
|
||||
trap 'rm -rf "$TMP" "$LOG"' EXIT
|
||||
|
||||
if ! bun port-to-omp.mjs --out "$TMP" >"$LOG" 2>&1; then
|
||||
echo "pre-commit: port regeneration failed (CI would fail too) — output:" >&2
|
||||
tail -20 "$LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! (cd "$TMP" && bun run typecheck) >"$LOG" 2>&1; then
|
||||
echo "pre-commit: port typecheck failed (this is what CI runs) — output:" >&2
|
||||
tail -30 "$LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -71,8 +71,9 @@ noise the user cannot act on.
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||
`__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
@@ -87,8 +87,9 @@ noise the user cannot act on.
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||
`__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
@@ -36,8 +36,9 @@ noise the user cannot act on.
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||
`__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
@@ -100,8 +100,9 @@ noise the user cannot act on.
|
||||
|
||||
## Skip (directory names — never descend into)
|
||||
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.next`, `.nuxt`, `.pygienium`, `.ralpi`,
|
||||
`.svelte-kit`, `.svn`, `.turbo`, `.vscode`, `__pycache__`, `build`, `coverage`,
|
||||
`.cache`, `.git`, `.hg`, `.idea`, `.netlify`, `.next`, `.nuxt`, `.output`,
|
||||
`.pygienium`, `.ralpi`, `.svelte-kit`, `.svn`, `.turbo`, `.vercel`, `.vscode`,
|
||||
`__pycache__`, `build`, `coverage`,
|
||||
`dist`, `node_modules`, `out`, `vendor`, `venv` (and `.venv`)
|
||||
|
||||
## Skip (file patterns)
|
||||
|
||||
@@ -246,7 +246,7 @@ async function runSessionToCompletion(
|
||||
session: AgentSession,
|
||||
opts: AgentTaskOptions,
|
||||
): Promise<AgentRunResult> {
|
||||
const acc: SessionEventAccumulator = { text: "" };
|
||||
const acc: SessionEventAccumulator = { text: "", sawMessage: false };
|
||||
try {
|
||||
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
||||
applySessionEvent(acc, event, opts.onEvent);
|
||||
@@ -259,16 +259,15 @@ async function runSessionToCompletion(
|
||||
unsubscribe();
|
||||
// Surface session errors that didn't throw but left no useful output.
|
||||
// A session ending with stopReason "error" and no text means the model
|
||||
// call failed silently — treat that as a failed run, not ok:true.
|
||||
if (acc.errorMessage) {
|
||||
return { ok: false, text: acc.text, error: acc.errorMessage };
|
||||
}
|
||||
if (!acc.text.trim() && acc.stopReason === "error") {
|
||||
return {
|
||||
ok: false,
|
||||
text: acc.text,
|
||||
error: "sub-agent session ended in error with no output.",
|
||||
};
|
||||
// call failed silently — treat that as a failed run, not ok:true. A
|
||||
// session that settled with NO text and NO observed events means the
|
||||
// model never produced anything at all (dead provider stream, failed
|
||||
// start) — reporting that as a successful analysis would let an empty
|
||||
// scan masquerade as a clean one, and the check's verify hook would
|
||||
// fail only later with a confusing "artifact missing" error.
|
||||
const sessionError = emptySessionError(acc);
|
||||
if (sessionError) {
|
||||
return { ok: false, text: acc.text, error: sessionError };
|
||||
}
|
||||
return { ok: true, text: acc.text };
|
||||
} catch (err) {
|
||||
@@ -290,12 +289,34 @@ async function runSessionToCompletion(
|
||||
export interface SessionEventAccumulator {
|
||||
/** Joined assistant text seen so far (text_delta stream). */
|
||||
text: string;
|
||||
/** Whether any assistant-message or tool event was observed at all. */
|
||||
sawMessage: boolean;
|
||||
/** stopReason of the final assistant message, when reported. */
|
||||
stopReason?: string;
|
||||
/** errorMessage of the final assistant message, when reported. */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a settled session's capture: `undefined` when the result is a
|
||||
* legitimate (possibly empty-text) outcome, else the error that should fail
|
||||
* the run. Kept pure so the decision is unit-testable without a session.
|
||||
*/
|
||||
export function emptySessionError(
|
||||
acc: SessionEventAccumulator,
|
||||
): string | undefined {
|
||||
if (acc.errorMessage) return acc.errorMessage;
|
||||
if (!acc.text.trim()) {
|
||||
if (acc.stopReason === "error") {
|
||||
return "sub-agent session ended in error with no output.";
|
||||
}
|
||||
if (!acc.sawMessage) {
|
||||
return "sub-agent session settled with no output — no assistant message or tool activity was observed. Check model/provider connectivity, then resume.";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one session event into the running accumulator and forward the
|
||||
* stream-driving events to the chat.
|
||||
@@ -316,6 +337,16 @@ export function applySessionEvent(
|
||||
): void {
|
||||
if (!event) return;
|
||||
try {
|
||||
// Any message or tool event means the session actually ran — a settled
|
||||
// capture with none of these is a dead session, not an empty scan.
|
||||
if (
|
||||
event.type === "message_update" ||
|
||||
event.type === "message_end" ||
|
||||
event.type === "tool_execution_start" ||
|
||||
event.type === "tool_execution_end"
|
||||
) {
|
||||
acc.sawMessage = true;
|
||||
}
|
||||
if (
|
||||
event.type === "message_update" &&
|
||||
event.assistantMessageEvent?.type === "text_delta"
|
||||
|
||||
@@ -56,6 +56,13 @@ export const SCOPE_EXCLUDE_DIRS: ReadonlySet<string> = new Set([
|
||||
".nuxt",
|
||||
".turbo",
|
||||
".svelte-kit",
|
||||
// Framework/deploy build output: Nitro, Vercel, Netlify artifact dirs.
|
||||
// Generated bundles dominate a naive marker/stub scan (freno-dev alone
|
||||
// flagged 119 of 124 candidates inside `.output/`/`.vercel/` minified
|
||||
// bundles) and must never feed the pre-scan or the agent's file walk.
|
||||
".output",
|
||||
".vercel",
|
||||
".netlify",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
|
||||
@@ -124,6 +124,21 @@ const MAX_CANDIDATES = 500;
|
||||
const FALLBACK_CAP = 16;
|
||||
/** Candidate list embedded in the live prompt is truncated at this many. */
|
||||
const PROMPT_CAP = 40;
|
||||
/**
|
||||
* Max characters of a candidate's code line embedded in the task prompt.
|
||||
* Generated/bundled single lines can be hundreds of KB (e.g. minified assets
|
||||
* sneaking past scope); embedding them wholesale balloons the task to
|
||||
* megabytes and chokes the sub-agent. `path:line` plus a truncated prefix is
|
||||
* enough to classify — the agent can read the file for full context.
|
||||
*/
|
||||
const CODE_DISPLAY_CAP = 160;
|
||||
|
||||
/** Truncate a candidate's code line for prompt embedding. */
|
||||
function displayCode(code: string): string {
|
||||
return code.length > CODE_DISPLAY_CAP
|
||||
? `${code.slice(0, CODE_DISPLAY_CAP)}…`
|
||||
: code;
|
||||
}
|
||||
|
||||
/** Extract the declared function name from a header line, when present. */
|
||||
function headerName(line: string): string | undefined {
|
||||
@@ -369,7 +384,7 @@ function renderFindings(
|
||||
parts.push(`## ${title}`);
|
||||
list.slice(0, FALLBACK_CAP).forEach((c, i) => {
|
||||
parts.push(
|
||||
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line} — ${c.code} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
|
||||
`### ${i + 1}. ${relative(cwd, c.path)}:${c.line} — ${displayCode(c.code)} | snippet: ${c.snippet} | context: ${c.context || relative(cwd, c.path)}`,
|
||||
);
|
||||
});
|
||||
if (list.length > FALLBACK_CAP) {
|
||||
@@ -434,7 +449,7 @@ export async function buildTodosScanTask(
|
||||
|
||||
const candidateList = candidates
|
||||
.slice(0, PROMPT_CAP)
|
||||
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${c.code}`);
|
||||
.map((c) => ` - ${relative(cwd, c.path)}:${c.line} [${c.kind}] ${displayCode(c.code)}`);
|
||||
if (candidates.length > PROMPT_CAP) {
|
||||
candidateList.push(
|
||||
` - … and ${candidates.length - PROMPT_CAP} more (truncated for brevity)`,
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
applySessionEvent,
|
||||
emptySessionError,
|
||||
type SessionEventAccumulator,
|
||||
} from "../src/agent-runner.js";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent";
|
||||
|
||||
function fresh(): SessionEventAccumulator {
|
||||
return { text: "" };
|
||||
return { text: "", sawMessage: false };
|
||||
}
|
||||
|
||||
/** Build a typed-as-unknown event so malformed shapes compile in tests. */
|
||||
@@ -134,4 +135,54 @@ describe("applySessionEvent", () => {
|
||||
expect(forwarded).toEqual([]);
|
||||
expect(acc.text).toBe("x");
|
||||
});
|
||||
|
||||
it("marks sawMessage when any message or tool event is observed", () => {
|
||||
const fromUpdate = fresh();
|
||||
applySessionEvent(fromUpdate, event({ type: "message_update" }));
|
||||
expect(fromUpdate.sawMessage).toBe(true);
|
||||
|
||||
const fromEnd = fresh();
|
||||
applySessionEvent(fromEnd, event({ type: "message_end", message: null }));
|
||||
expect(fromEnd.sawMessage).toBe(true);
|
||||
|
||||
const fromTool = fresh();
|
||||
applySessionEvent(fromTool, event({ type: "tool_execution_start" }));
|
||||
expect(fromTool.sawMessage).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emptySessionError", () => {
|
||||
it("fails a session that settled with no text and no observed events", () => {
|
||||
expect(emptySessionError({ text: "", sawMessage: false })).toContain(
|
||||
"no output",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails an empty session whose final message reported stopReason error", () => {
|
||||
expect(
|
||||
emptySessionError({ text: "", sawMessage: true, stopReason: "error" }),
|
||||
).toBe("sub-agent session ended in error with no output.");
|
||||
});
|
||||
|
||||
it("surfaces a recorded errorMessage regardless of text", () => {
|
||||
expect(
|
||||
emptySessionError({
|
||||
text: "partial output",
|
||||
sawMessage: true,
|
||||
errorMessage: "upstream 529",
|
||||
}),
|
||||
).toBe("upstream 529");
|
||||
});
|
||||
|
||||
it("accepts an empty-text session that demonstrably ran (tool activity)", () => {
|
||||
expect(
|
||||
emptySessionError({ text: "", sawMessage: true, stopReason: "end_turn" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts any session with text", () => {
|
||||
expect(
|
||||
emptySessionError({ text: "report", sawMessage: false }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,30 @@ describe("detectTodoStubs", () => {
|
||||
loud.some((h) => h.path.endsWith("fetch.rs") && h.snippet === "todo!("),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never descends into build/deploy output directories", async () => {
|
||||
// Generated bundles under framework build dirs must not feed the
|
||||
// pre-scan: they dominate candidate counts with minified noise (the
|
||||
// freno-dev failure flagged 119 of 124 candidates inside
|
||||
// `.output`/`.vercel` bundles, ballooning the scan task to 2.5 MB).
|
||||
for (const rel of [
|
||||
join(".output", "public", "bundle.js"),
|
||||
join(".vercel", "output", "static", "app.js"),
|
||||
join(".netlify", "functions", "bundle.js"),
|
||||
]) {
|
||||
const full = join(dir, rel);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(
|
||||
full,
|
||||
"// TODO: bundle placeholder\nfunction f(){ return 0; }\nthrow new Error('not implemented');\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const hits = await detectTodoStubs(dir);
|
||||
expect(
|
||||
hits.filter((h) => /(?:\.output|\.vercel|\.netlify)[/\\]/.test(h.path)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todos check", () => {
|
||||
@@ -334,4 +358,25 @@ describe("todos check", () => {
|
||||
);
|
||||
expect(task).toContain("| new: 0 | resolved: 4 |");
|
||||
});
|
||||
|
||||
it("truncates giant single-line candidates so the task prompt stays bounded", async () => {
|
||||
// A minified/generated single line can be hundreds of KB; embedding it
|
||||
// wholesale ballooned the freno-dev task to 2.5 MB and choked the
|
||||
// analysis agent. The task must carry a truncated prefix, never the
|
||||
// full line.
|
||||
const long = `// TODO: ${"x".repeat(400)}`;
|
||||
await writeFile(
|
||||
join(cwd, "huge.ts"),
|
||||
`${long}\nexport function f() { return 0; }\n`,
|
||||
"utf8",
|
||||
);
|
||||
const task = await buildTodosScanTask(cwd, {
|
||||
cwd,
|
||||
target: cwd,
|
||||
fix: false,
|
||||
rest: [],
|
||||
});
|
||||
expect(task).not.toContain("x".repeat(400));
|
||||
expect(task).toContain("…");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user