a0576a702a
* opencode v2: harness adapted to opencode-ai 1.15+ SDK-v2 / Effect-ts CLI rewrite
Bumps `opencode-ai` from `1.1.56` → `1.15.1` and ports the harness to the
v2 NDJSON event contract. The legacy `opencode.ts` is kept as reference;
`opencode_v2.ts` is the active runner via `agents/index.ts`.
Why: `1.1.56` doesn't echo Gemini `thought_signature` back through the
MCP tool-call serializer, so direct-Google reviews 400 on the 3rd-ish
tool call. The fix only exists in the `1.14.x`+ line, which also ships
the SDK-v2 / Effect-ts CLI rewrite — taking the rewrite is mandatory.
Also unblocks the Codex ChatGPT-subscription auth path.
Surface area:
- drop `init` / `message` / `result` / `tool_result` event types and
handlers (no longer emitted at v1.14+ per upstream
`cli/cmd/run.ts:588-601`).
- `tool_use` is now a single event covering both `state.status:
"completed"` and `"error"`. duration / subagent-finish bookkeeping
moves from the v1 `tool_result` handler into the consolidated
`tool_use` handler.
- new `reasoning` event handler — gated on `--thinking`, surfaces
Gemini-3 / OpenAI / Anthropic thinking blocks. `--thinking` added to
`baseArgs`.
- drop `pendingTaskDispatches` FIFO + `knownNonTaskCallIDs` set: at
v1.15 the `task` tool callID is stable across the whole
`tool-input-* → tool-call → tool-result/tool-error` chain
(`session/processor.ts:282-330`). exact-match map is sufficient.
- drop `experimental.batch_tool: true` from injected config — declared
but inert at v1.15. re-add once upstream wires it back.
- bin path: `bin/opencode` → `bin/opencode.exe` (postinstall renames
the platform-specific binary into `opencode.exe` for every OS now).
Validated locally:
- `pnpm test` 610/610 ✓
- `pnpm play --raw` end-to-end with Anthropic via OpenRouter ✓
- `pnpm play --raw` with `google/gemini-3.1-pro-preview`: 6 tool calls,
multiple reasoning blocks visible, `set_output` propagates, exit 0 ✓
(this is the headline `thought_signature` fix)
- runtest opencode: smoke ✓, restricted ✓, nobash ✓, token-exfil ✓
- runtest opencode: skill-invoke and mcpmerge fail (model-behavior
drift on the new system prompt; wiring confirmed intact via direct
repro showing both `robinMCP` and `pullfrog` MCP tools exposed).
Tracked for follow-up; does not gate the migration.
Plugin (`opencodePlugin.ts`) and skill discovery paths are unchanged at
v1.15 — verified upstream and reused as-is. Bus subscription via
`bus.subscribeAll()` and the `event` hook still fan out every payload.
* model-smoke: bump opencode bin path to opencode.exe (v1.14+ rename)
The v1.14+ postinstall.mjs renames the platform-specific binary to
`bin/opencode.exe` for every OS (incl. linux/darwin), not just Windows.
Mirrors the fix in action/agents/opencode_v2.ts.
* opencode v2: set PWD env explicitly to fix skill / project-config discovery
Root cause for skill-invoke + mcpmerge harness regressions: opencode-ai 1.15
reads `process.env.PWD` first (with `process.cwd()` as fallback) when
resolving the SDK client's `directory` parameter — see upstream
`cli/cmd/run.ts:282`:
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
We pass `cwd: repoDir` to spawn, but the child inherits the harness's PWD
via `...process.env`. Under `pnpm runtest` (and `pnpm play`) PWD is the
`action/` directory, not the cloned test repo. Result: opencode creates
two instances per session — one at `process.cwd()` (correct) and one at
`PWD` (wrong) — and the agent's session runs in the PWD-derived one,
which can't see the project's `.opencode/skills/` or `.claude/skills/`.
Empirically traced via the full opencode stderr trace under the runtest
harness: `service=skill count=3 init` (no `pullfrog-skill-check`) plus a
second `service=default directory=<harness-pwd> creating instance` line
per run. With `PWD=repoDir` set explicitly, `count=4 init` includes the
test skill, the agent reaches for `skill({"name":"pullfrog-skill-check"})`
exactly as the validator expects, and mcpmerge's `robinMCP_get_test_value`
becomes accessible too.
Validated locally: skill-invoke-opencode ✓, mcpmerge-opencode ✓, smoke ✓,
restricted ✓, nobash ✓, token-exfil ✓ (flaked once on a model-narration
match, passes on retry; unrelated to PWD).
* opencode v2: drop ThinkingTimer; use opencode's reasoning.part.time directly
opencode-ai 1.15 emits `reasoning` parts with `time.start` / `time.end`
on terminal state (`cli/cmd/run.ts:671`), giving us a precise per-block
"thought for X s" duration straight from the runtime. The v1
ThinkingTimer heuristic — measuring wall-clock between markToolResult
and the next markToolCall — was an approximation when no native source
existed; with v2 it's redundant and noisy (it would log alongside the
real reasoning event, and conflated network latency with model thinking).
Removed: `ThinkingTimer` import, `thinkingTimers` Map, `timerFor()`
helper, both `markToolCall` / `markToolResult` call sites in `tool_use`.
The `reasoning` handler now reads `part.time.start/end` directly and
prefixes the visible preview with `(X.Ys)`.
Output before: `» thinking: <preview>` + `» thought for 4.0s` (separate)
Output now: `» thinking (4.0s): <preview>` (one line, sourced)
For models that don't emit reasoning (Sonnet without extended thinking,
GPT-4o, etc.), there's just no thinking line — which matches reality
better than the gap-heuristic, which would fire on any pause >3s
including provider-side latency that wasn't actual model reasoning.
Validated locally: skill-invoke ✓, mcpmerge ✓, smoke ✓, Gemini play
shows `» thinking (4.0s)` and `» thinking (0.8s)` from real durations.
* claude.ts: same PWD fix as opencode v2; entryPost: refresh stale comment
claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
additional-working-directory when it differs from `process.cwd()` (per the
bundled cli.js: `let H = process.env.PWD; if (H && H !== Y7() && ...)
j.set(H, { path: H, source: "session" })`). Without overriding PWD on the
spawn env, claude inherits the harness's PWD via `...process.env` — under
`pnpm runtest` / `pnpm play` that's `action/`, not the cloned test repo —
and adds the wrong dir to the agent's allowed working set.
Symmetric to the opencode v2 fix in 52337f9. Pre-empts the same class of
"agent's session sees the wrong cwd" failures on the claude side.
Also refresh the stale `action/agents/opencode.ts` reference in
entryPost.ts to point at opencode_v2.ts (the active runner), with the v1
file noted as kept-for-reference.
* opencode: extract shared helpers into opencodeShared.ts; v2 cleanup
Code-quality pass on the v2 work:
1. New `agents/opencodeShared.ts` (144 lines) for genuinely-shared helpers
between v1 and v2:
- `OpenCodeConfig` type
- `geminiHighThinkingOverrides()` (registry-driven Gemini thinking pin)
- `buildReviewerAgentConfig()` (reviewfrog config builder, was in v1
and re-imported by v2 via a back-reference)
- `installOpencodeCli({ binPath })` (parameterized — v1 passes
`bin/opencode`, v2 passes `bin/opencode.exe` via a per-version
`installCli` lambda; matches each pinned version's npm shape)
- `autoSelectModel()` + `getOpenCodeModels()` model-registry fallback
v2 drops the `import { ... } from "./opencode.ts"` back-reference; v1
keeps a one-line `export { geminiHighThinkingOverrides }` re-export
so `opencode.test.ts` keeps working unchanged. Once v1 is retired
(post burn-in) opencodeShared collapses back into v2.
2. `opencode_v2.ts` cleanup:
- drop dead state (`currentStepId`, `stepHistory` were write-only —
their reader was the v1 `tool_result` handler we deleted)
- hoist `state` in `tool_use` handler; replace nested-ternary payload
extraction with a `terminalPayload(state)` helper
- extract `formatPartDuration(time)` for the reasoning-block
"(X.Ys)" suffix
- tighten `OpenCodeBusEnvelopeEvent` type to include `tool` /
`callID` fields directly, drop the `partWithToolFields` cast
- trim docblocks per AGENTS.md "≤ 2-3 lines per code line": reasoning
handler, tool_use handler, bus envelope handler all shortened
- `step_start` becomes an explicit `() => {}` no-op so the dispatcher
doesn't log "unhandled event" for every step
3. `subagentRegistration.test.ts` retargeted at the new file split —
reads opencodeShared.ts for the buildReviewerAgentConfig assertions
and opencode_v2.ts for the orchestrator-model wire-through.
Net: -306 source lines (1339+1130 → 1228+1031+144). Tests + lint + format
+ typecheck all green; skill-invoke-opencode ✓ and smoke ✓ verified
against the refactored v2 runtime.
* opencode v2: address PR review feedback
Three fixes from the inline review threads on #767:
1. Activity-diagnostic ordering bug (Copilot review at L705): the chunk-
level `markActivity()` resets the module-level idle counter, so the
per-event `getIdleMs()` sample inside the dispatch loop was always
~0ms — the "no activity for Xs" diagnostic never fired. Replaced with
a runner-local `lastEventAt` so we measure real event-to-event silence
instead of chunk-arrival latency. Drop the unused `getIdleMs` import.
2. TDZ-defensive hoist (Pullfrog review nit): `agentErrorEvent`,
`lastProviderError`, and `recentStderr` are closed over by the
`handlers` const but were declared after it. No current bug because
handlers only fire inside the awaited `spawn()`, but a future
refactor that triggers a handler synchronously during setup would
surface a TDZ. Hoisted above `handlers`.
3. `step_finish.part.tokens.reasoning` follow-up (Pullfrog review at
L566): leave a `TODO` comment marking the gap until `AgentUsage`
grows a `reasoningTokens` field — separate PR with schema work.
Cost totals stay correct because `part.cost` is summed independently.
Other thread states for the record:
- Copilot L63 (geminiHighThinkingOverrides import from legacy): already
fixed by the opencodeShared.ts extraction in 83a7cab.
- Copilot L672 (ThinkingTimer over-reports on terminal events): already
fixed by dropping ThinkingTimer in a1e536b — we use opencode's own
`reasoning.part.time.{start,end}` for thinking durations now.
- Pullfrog L642 (onToolUse double-fire on subagent dispatch): re-checked
the bus-envelope flow; the plugin filters orchestrator events except
for status=running task dispatches, and bus-envelope returns before
calling handlers.tool_use on those. No double-fire under current code.
Validated: 610/610 unit tests, lint + format + typecheck clean,
skill-invoke-opencode ✓.
* DX: flip pnpm play / pnpm runtest to docker-by-default
Restores the script shape wiki/docker.md has documented since the docker
rewrite (#750). PR #756 inadvertently reverted action/package.json's
gha/play/runtest scripts to host-only and dropped the :local variants;
the wiki kept the new shape, so docs and reality drifted. The OpenCode-v2
migration agent ran `pnpm play --raw …` host-side throughout because the
host entry was the only thing that existed.
scripts (root → action):
- pnpm play → pnpm -C action gha play.ts (docker, default)
- pnpm play:local → pnpm -C action play:local (host)
- pnpm runtest → pnpm -C action gha test/run.ts (docker, default)
- pnpm runtest:local → pnpm -C action runtest:local (host)
- pnpm gha is restored in action/package.json (re-adds `node gha.ts`)
action/package.json deliberately ships only the :local variants — bare
`pnpm -C action play` now errors instead of silently bypassing docker.
This is a tradeoff per the user prompt's "consider whether NAMES should
change" hint: the explicit error is worth the small CI churn.
CI workflows: `.github/workflows/test.yml` and
`action/.github/workflows/test.yml` flipped from `pnpm runtest …` to
`pnpm runtest:local …`. Semantics unchanged — they still execute
`node test/run.ts` directly on the GHA Linux runner; nesting docker on
GHA is unnecessary overhead. Only the script name changed to match the
new package.json.
Webhook tester: the existing root `pnpm play` was actually a webhook
handler smoke harness (root play.ts), unrelated to the action runtime.
Renamed root play.ts → webhook.ts and exposed it as `pnpm webhook` so
`pnpm play` can carry the docker-by-default action shortcut without
collision. README updated.
File headers updated:
- action/play.ts: invocation block now points at `pnpm play` /
`pnpm play:local`
- action/test/run.ts: same
- action/gha.ts: usage block calls out the new shortcut wrappers
AGENTS.md: extended the existing "local sanity checks of action tool
logic" rule with the play / play:local / runtest / runtest:local
selection guidance and the `cd action; pnpm play` footgun note.
wiki/docker.md unchanged — already described the now-real shape.
* test/crossagent: add codex-auth smoke
Pins openai/gpt-5.5 (in opencode's Codex ALLOWED_MODELS) and runs the
full opencode harness against the env-provided CODEX_AUTH_JSON. Verifies:
- installCodexAuth() materializes auth.json under the test HOME
- opencode routes openai requests through ChatGPT subscription auth
(no OPENAI_API_KEY in env, AT path forced via expires: 0)
- the refresh chain advances during the run (refresh_token rotates)
- detectCodexRefresh() would surface the rotation to entryPost.ts
The post-hook write-back fetch isn't reachable from `pnpm runtest`
(it's a separate GHA `post:` step). The integration boundary that
matters end-to-end is "did the on-disk auth.json change in a way
detectCodexRefresh recognizes" — that's exactly what this test asserts.
CI wiring (already committed in a1c1fd4f as part of the DX flip):
- .github/workflows/test.yml: CODEX_AUTH_JSON via secrets in
action-agents env block
- action/.github/workflows/test.yml: same; codex-auth in the
hardcoded test matrix with a claude exclude
The provisioning step on the user's side is `gh secret set
CODEX_AUTH_JSON --repo pullfrog/app < auth.json`.
ci.test.ts: expectedAgentEnvVars now includes provider
`managedCredentials` so the "env vars cover all provider API keys"
invariant stays self-correcting as more managed credentials land.
* docs(codex-auth): make storage requirement unmissable
A previous reviewing agent on this branch came away thinking
`CODEX_AUTH_JSON` could live in GitHub Actions secrets. It can't —
`entryPost.ts` rewrites the rotated refresh token after every run, and GH
Actions secrets are immutable at runtime, so any non-Pullfrog-Postgres
storage breaks the refresh chain on the first rotation (~1h silent
expiry).
- wiki/codex-auth.md: prominent `[!IMPORTANT]` callout above the fold,
with the words "GitHub Actions secrets DO NOT WORK" verbatim and an
enumeration of broken alternatives.
- action/utils/codexHome.ts + action/entryPost.ts: header comments now
loudly contrast Pullfrog secret store vs GH Actions and explain the
writeback constraint.
- AGENTS.md: terse one-bullet rule next to the model-resolution rule so
future agents don't repeat the mistake.
- .github/workflows/test.yml + action/.github/workflows/test.yml: added a
comment marking the existing `secrets.CODEX_AUTH_JSON` injection as a
CI smoke-testing shortcut, not the canonical pattern. CI wiring itself
unchanged per scope.
* auth codex: auto-open device URL, drop --scope flag
- detect `https://auth.openai.com/codex/device...` from codex CLI output
and best-effort launch it in the user's default browser (open / xdg-open
/ cmd start, wslview fallback on linux). gated so we only open once per
flow; failures are swallowed so manual copy-paste still works.
- drop the `--scope` flag entirely. the device-code flow is fundamentally
interactive (browser approval), so a "skip-the-prompt" flag for just one
of the prompts was dead weight. collapses scope selection to "always
prompt on org-owned, always account on user-owned".
* rename gha→docker, flip play/runtest defaults to host
the previous shape conflated "real GitHub Actions" with the local docker
container that mocks it, and made the slow docker path the default for
fast-iteration scripts.
- `action/gha.ts` → `action/docker.ts` (banner, --doctor, --help, image
tag `pullfrog-docker:*`, volume `pullfrog-docker-node-modules-*`,
tmpdir, error messages)
- `pnpm play` / `pnpm runtest` now default to host (fast iteration);
`pnpm play:docker` / `pnpm runtest:docker` run inside the container
- `pnpm gha` → `pnpm docker` (the container runner shortcut)
- `pnpm webhook` → `pnpm play:webhook` (fits the play: namespace; the
bare name implied a webhook server, which hookdeck-cli already is)
- update docs (`wiki/{docker,action-tests,billing,adversarial,browser}.md`,
`README.md`, `AGENTS.md`), CI workflows
(`.github/workflows/test.yml`, `action/.github/workflows/test.yml`),
and code headers (`action/{play,test/run,utils/runFixture}.ts`,
`webhook.ts`, `action/test/coverage.ts`)
`action/commands/gha.ts` keeps its name — it's the real GitHub Actions
entry point for the `pullfrog gha` CLI command (not the docker mock).
* fix(codex): route post-hook writeback through apiFetch + conditional skip
Three threads addressing PR #767 followups.
action/entryPost.ts: replace raw fetch() with apiFetch() so the
PUT /api/runtime/secret call carries the x-vercel-protection-bypass
header/query when targeting a preview deployment. raw fetch silently
401s against the Vercel SSO gate, so every preview-env Codex run was
losing its rotated refresh token. production is unaffected (no SSO).
action/test/crossagent/codexAuth.ts: gate the test on CODEX_AUTH_JSON
via new TestRunnerOptions.skipIf hook. when the secret is absent
(forks, contributors without it), runTestForAgent short-circuits to a
passing-with-skipped ValidationResult before any agent spawn — so the
matrix's fail-fast: true setting doesn't cascade-cancel siblings. CI
on pullfrog/app and dev-local with .env both still run the test for
real. printSingleValidation/printResults now render skipped entries
distinctly.
doc/comment drift:
- docs/codex-auth.mdx, wiki/codex-auth.md: drop stale --scope flag
mention (removed in 10be96db, scope is now always interactively
prompted or implicit).
- wiki/codex-auth.md: tighten Claude-defense wording — materialization
is agent-gated (opencode/opencode_v2 harness), not model-gated;
opencode runs with non-OpenAI models still materialize the file,
it's just not read.
- action/Dockerfile, action/docker-entrypoint.sh: pnpm gha / gha.ts
→ pnpm docker / docker.ts (renamed in a2a63929).
- app/api/runtime/secret/route.ts: refer to the save-time scope prompt
instead of the dropped --scope flag.
* smoke: force ≥2 tool calls; document test-bar in wiki + AGENTS
upgrade crossagent/smoke prompt to call pullfrog_git status before
set_output. this exercises the 2nd model→agent round-trip across every
providers-live flagship, catching bugs like the Gemini thought_signature
echo that single-tool-call tests can't see.
also adds the "bar for adding new LLM-driven tests" section to
wiki/action-tests.md and an extension to the existing AGENTS.md
no-tests rule pointing at it — prefer upgrading existing matrix entries
over adding new ones.
local: pnpm runtest smoke opencode passes against both
anthropic/claude-sonnet-4-6 and google/gemini-pro.
---------
Co-authored-by: Colin McDonnell <colinmcd94@M1chelle.local>
538 lines
17 KiB
TypeScript
538 lines
17 KiB
TypeScript
import { existsSync, readdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { config } from "dotenv";
|
|
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
|
|
import {
|
|
type AgentResult,
|
|
agents,
|
|
getPrefix,
|
|
printResults,
|
|
printSingleValidation,
|
|
runAgentStreaming,
|
|
type TestRunnerOptions,
|
|
type TestTag,
|
|
type ValidationResult,
|
|
validateResult,
|
|
} from "./utils.ts";
|
|
|
|
/**
|
|
* unified test runner for all agent tests.
|
|
*
|
|
* invoke from the repo root:
|
|
* pnpm runtest [filters…] # host, in-process — fast iteration (default)
|
|
* pnpm runtest:docker [filters…] # local docker container that mocks GHA
|
|
* pnpm docker test/run.ts [filters…] # explicit container form (equivalent to `pnpm runtest:docker`)
|
|
*
|
|
* filters can be test names, tags, or agent names:
|
|
* pnpm runtest # run all tests (excludes adhoc-tagged tests)
|
|
* pnpm runtest smoke # run tests named "smoke" or tagged "smoke"
|
|
* pnpm runtest opencode # run all tests for opencode only
|
|
* pnpm runtest security # run all tests tagged "security"
|
|
* pnpm runtest agnostic # run all agnostic-tagged tests (with opencode)
|
|
* pnpm runtest adhoc # run all adhoc-tagged tests
|
|
* pnpm runtest smoke opencode # run smoke tests for opencode only
|
|
*
|
|
* special tags:
|
|
* - "agnostic": runs with opencode only, excluded when filtering by agent
|
|
* - "adhoc": excluded from default runs, must be explicitly requested
|
|
*
|
|
* see wiki/docker.md for when host vs container matters.
|
|
*/
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
export const actionDir = join(__dirname, "..");
|
|
|
|
config({ path: join(actionDir, ".env") });
|
|
config({ path: join(actionDir, "..", ".env") });
|
|
|
|
const mcpPortBase = 49000;
|
|
let nextMcpPort = mcpPortBase;
|
|
|
|
function allocateMcpPort(): number {
|
|
const port = nextMcpPort;
|
|
nextMcpPort += 1;
|
|
return port;
|
|
}
|
|
|
|
type TestInfo = {
|
|
name: string;
|
|
config: TestRunnerOptions;
|
|
};
|
|
|
|
type CancelState = {
|
|
canceled: boolean;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
type TestModule = {
|
|
test?: TestRunnerOptions;
|
|
tests?: Record<string, TestRunnerOptions>;
|
|
};
|
|
|
|
// load all tests from all directories
|
|
async function loadAllTests(): Promise<TestInfo[]> {
|
|
const testInfos: TestInfo[] = [];
|
|
const dirs = ["crossagent", "agnostic", "adhoc"];
|
|
|
|
for (const dir of dirs) {
|
|
const dirPath = join(__dirname, dir);
|
|
if (!existsSync(dirPath)) continue;
|
|
|
|
const files = readdirSync(dirPath).filter((f) => f.endsWith(".ts"));
|
|
for (const file of files) {
|
|
const filePath = join(dirPath, file);
|
|
const module = (await import(filePath)) as TestModule;
|
|
|
|
if (module.test) {
|
|
testInfos.push({ name: module.test.name, config: module.test });
|
|
} else if (module.tests) {
|
|
const entries = Object.entries(module.tests);
|
|
for (const entry of entries) {
|
|
testInfos.push({ name: entry[0], config: entry[1] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return testInfos;
|
|
}
|
|
|
|
// check if test has a specific tag
|
|
function hasTag(test: TestInfo, tag: TestTag): boolean {
|
|
return test.config.tags?.includes(tag) ?? false;
|
|
}
|
|
|
|
type ParsedArgs = {
|
|
filters: string[]; // test names or tags
|
|
agentFilters: string[];
|
|
};
|
|
|
|
function parseArgs(args: string[], allTests: TestInfo[]): ParsedArgs {
|
|
const testNames = new Set(allTests.map((t) => t.name));
|
|
const allTags = new Set(allTests.flatMap((t) => t.config.tags ?? []));
|
|
|
|
const filters: string[] = [];
|
|
const agentFilters: string[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (agents.includes(arg as (typeof agents)[number])) {
|
|
agentFilters.push(arg);
|
|
} else if (testNames.has(arg) || allTags.has(arg as TestTag)) {
|
|
filters.push(arg);
|
|
} else {
|
|
console.error(`unknown argument: ${arg}`);
|
|
console.error(`available tests: ${[...testNames].join(", ")}`);
|
|
console.error(`available tags: ${[...allTags].join(", ")}`);
|
|
console.error(`available agents: ${agents.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
return { filters, agentFilters };
|
|
}
|
|
|
|
// filter tests based on filters (names or tags)
|
|
function filterTests(allTests: TestInfo[], filters: string[]): TestInfo[] {
|
|
if (filters.length === 0) {
|
|
// default: exclude adhoc tests
|
|
return allTests.filter((t) => !hasTag(t, "adhoc"));
|
|
}
|
|
|
|
// match tests by name or tag
|
|
return allTests.filter((t) => {
|
|
for (const filter of filters) {
|
|
if (t.name === filter || hasTag(t, filter as TestTag)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
type RunContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
cancelState: CancelState;
|
|
results: Map<string, ValidationResult>;
|
|
};
|
|
|
|
function getRunKey(test: string, agent: string): string {
|
|
return `${test}::${agent}`;
|
|
}
|
|
|
|
type CanceledValidationContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
signal: NodeJS.Signals;
|
|
};
|
|
|
|
function buildCanceledValidation(ctx: CanceledValidationContext): ValidationResult {
|
|
return {
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
passed: false,
|
|
canceled: true,
|
|
checks: [{ name: "canceled", passed: false }],
|
|
output: `canceled by ${ctx.signal}`,
|
|
};
|
|
}
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RATE_LIMIT_BACKOFF_MS = 60_000; // 1 minute for rate limits
|
|
const FLAKY_RETRY_BACKOFF_MS = 5_000; // 5 seconds for transient failures
|
|
|
|
type RetryDecision = { retry: false } | { retry: true; reason: string; backoffMs: number };
|
|
|
|
/**
|
|
* determine if a failed test run should be retried.
|
|
*
|
|
* retryable (transient infrastructure failures):
|
|
* - rate limit errors from API providers
|
|
* - agent crashed/errored but no security-relevant checks failed
|
|
* (e.g., agent didn't call set_output due to MCP connection drop)
|
|
* - set_output not called — all output-dependent checks cascade fail
|
|
*
|
|
* NOT retryable (genuine test failures):
|
|
* - security checks failed (sandbox breach, token leak, etc.)
|
|
* - agent successfully ran and called set_output but produced wrong results
|
|
*/
|
|
// detect rate limit / quota errors across all providers. `\b429\b` uses word
|
|
// boundaries because a bare "429" substring false-matches UUIDs (e.g. MCP
|
|
// session ids like `...-4429-...`) and microsecond timestamps in agent stdout,
|
|
// which used to send transient failures down the 60s rate-limit retry path
|
|
// and push retries past the per-step CI timeout.
|
|
const RATE_LIMIT_PATTERNS: RegExp[] = [
|
|
/rate limit reached/i, // anthropic
|
|
/resource has been exhausted/i, // google/gemini
|
|
/quota exceeded/i, // google/gemini
|
|
/\b429\b/, // generic HTTP 429
|
|
/too many requests/i, // generic
|
|
];
|
|
|
|
function isRateLimited(output: string): boolean {
|
|
return RATE_LIMIT_PATTERNS.some((p) => p.test(output));
|
|
}
|
|
|
|
function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDecision {
|
|
// rate limit / quota exhaustion: agent never got to run properly
|
|
if (!result.success && isRateLimited(result.output)) {
|
|
return { retry: true, reason: "rate limited", backoffMs: RATE_LIMIT_BACKOFF_MS };
|
|
}
|
|
|
|
// already passed — no retry needed
|
|
if (validation.passed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// if the test has a set_output check and it failed, other check failures are
|
|
// cascade failures — validators gate their checks on `setOutputCalled && ...`
|
|
// so they always fail when there's no structured output.
|
|
// security-relevant checks (like no_leak_filtered, native_blocked) are designed
|
|
// to PASS when set_output wasn't called (defensive coding). so cascade failures
|
|
// are never genuine security findings — they're transient instruction-following
|
|
// issues (MCP connection drop, agent confusion, etc.).
|
|
const setOutputCheck = validation.checks.find((c) => c.name === "set_output");
|
|
if (setOutputCheck && !setOutputCheck.passed) {
|
|
// if the output contains rate limit indicators, use the longer backoff
|
|
// (the agent process may have succeeded but hit quota limits mid-run)
|
|
const rateLimited = isRateLimited(result.output);
|
|
return {
|
|
retry: true,
|
|
reason: rateLimited ? "rate limited (set_output cascade)" : "set_output not called (cascade)",
|
|
backoffMs: rateLimited ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS,
|
|
};
|
|
}
|
|
|
|
// set_output was called (or test has no set_output check) — if any other check
|
|
// failed, that's a genuine test failure with real data, not a cascade. don't retry.
|
|
const otherCheckFailed = validation.checks.some((c) => !c.passed && c.name !== "set_output");
|
|
if (otherCheckFailed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// agent process failed (non-zero exit) but no structured output to validate
|
|
if (!result.success) {
|
|
return { retry: true, reason: "agent process failed", backoffMs: FLAKY_RETRY_BACKOFF_MS };
|
|
}
|
|
|
|
return { retry: false };
|
|
}
|
|
|
|
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|
const testConfig = ctx.testInfo.config;
|
|
|
|
// runtime-evaluated skip: gate on env (e.g. CODEX_AUTH_JSON for codex-auth).
|
|
// skipped runs short-circuit before any agent spawn AND count as passing so
|
|
// a missing optional secret doesn't fail-fast cancel the rest of the matrix.
|
|
const skipReason = testConfig.skipIf?.();
|
|
if (skipReason) {
|
|
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
|
|
console.log(`${prefix} ⏭ skipped: ${skipReason}`);
|
|
const skipped: ValidationResult = {
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
passed: true,
|
|
canceled: false,
|
|
checks: [],
|
|
output: `skipped: ${skipReason}`,
|
|
skipped: true,
|
|
skipReason,
|
|
};
|
|
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), skipped);
|
|
return skipped;
|
|
}
|
|
|
|
const env: Record<string, string> = {};
|
|
if (testConfig.env) {
|
|
const entries = Object.entries(testConfig.env);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
if (testConfig.agentEnv) {
|
|
const agentEnv = testConfig.agentEnv.get(ctx.agent);
|
|
if (agentEnv) {
|
|
const entries = Object.entries(agentEnv);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
env.PULLFROG_AGENT = ctx.agent;
|
|
|
|
// override DB model to avoid mismatch when PULLFROG_AGENT forces a specific agent
|
|
// (DB model may belong to a different provider than the forced agent supports).
|
|
// precedence: testConfig.env > process.env.PULLFROG_MODEL > per-agent default.
|
|
// the process.env pass-through lets CI (models-live matrix) pin an alias per job.
|
|
if (!Object.hasOwn(env, "PULLFROG_MODEL")) {
|
|
if (process.env.PULLFROG_MODEL) {
|
|
env.PULLFROG_MODEL = process.env.PULLFROG_MODEL;
|
|
} else {
|
|
const defaultModels: Record<string, string> = {
|
|
claude: "anthropic/claude-sonnet-4-6",
|
|
opencode: "anthropic/claude-sonnet-4-6",
|
|
};
|
|
const model = defaultModels[ctx.agent];
|
|
if (model) {
|
|
env.PULLFROG_MODEL = model;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Object.hasOwn(env, "PULLFROG_MCP_PORT")) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
// pass repo setup commands to play.ts for pre-agent execution
|
|
if (testConfig.repoSetup) {
|
|
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
|
|
}
|
|
|
|
// build file-based env vars for MCP servers that don't inherit parent env
|
|
let fileEnv: Record<string, string> | undefined;
|
|
if (testConfig.fileAgentEnv) {
|
|
const agentFileEnv = testConfig.fileAgentEnv.get(ctx.agent);
|
|
if (agentFileEnv) {
|
|
fileEnv = {};
|
|
const entries = Object.entries(agentFileEnv);
|
|
for (const entry of entries) {
|
|
fileEnv[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
if (ctx.cancelState.canceled) break;
|
|
|
|
// allocate a fresh port on retries (previous server is gone)
|
|
if (attempt > 0) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
const result = await runAgentStreaming({
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
fixture: testConfig.fixture,
|
|
env,
|
|
fileEnv,
|
|
isCanceled: () => ctx.cancelState.canceled,
|
|
});
|
|
|
|
const validation = validateResult(result, testConfig.validator, {
|
|
test: ctx.testInfo.name,
|
|
expectFailure: testConfig.expectFailure,
|
|
});
|
|
|
|
// check if we should retry
|
|
if (attempt < MAX_RETRIES) {
|
|
const decision = shouldRetry(result, validation);
|
|
if (decision.retry) {
|
|
console.log(
|
|
`\n${prefix} ${decision.reason} — retrying in ${decision.backoffMs / 1000}s (retry ${attempt + 1}/${MAX_RETRIES})...\n`
|
|
);
|
|
await new Promise((r) => setTimeout(r, decision.backoffMs));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), validation);
|
|
return validation;
|
|
}
|
|
|
|
// should not reach here, but handle canceled state
|
|
return buildCanceledValidation({
|
|
testInfo: ctx.testInfo,
|
|
agent: ctx.agent,
|
|
signal: ctx.cancelState.signal ?? "SIGTERM",
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
|
|
const allTests = await loadAllTests();
|
|
const parsed = parseArgs(args, allTests);
|
|
|
|
// filter tests
|
|
const filteredTests = filterTests(allTests, parsed.filters);
|
|
|
|
if (filteredTests.length === 0) {
|
|
console.error("no tests to run");
|
|
process.exit(1);
|
|
}
|
|
|
|
// determine which agents to run
|
|
const agentsToRun = parsed.agentFilters.length > 0 ? parsed.agentFilters : [...agents];
|
|
|
|
// build list of test runs
|
|
type TestRun = { testInfo: TestInfo; agent: string };
|
|
const runs: TestRun[] = [];
|
|
|
|
for (const testInfo of filteredTests) {
|
|
const isAgnostic = hasTag(testInfo, "agnostic");
|
|
|
|
if (isAgnostic) {
|
|
// agnostic tests: skip if only filtering by agent, otherwise run with opencode
|
|
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
|
|
continue;
|
|
}
|
|
runs.push({ testInfo, agent: "opencode" });
|
|
} else {
|
|
// determine which agents to run for this test
|
|
const testAgents = testInfo.config.agents ?? agents;
|
|
const effectiveAgents = agentsToRun.filter((a) => testAgents.includes(a));
|
|
for (const agent of effectiveAgents) {
|
|
runs.push({ testInfo, agent });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (runs.length === 0) {
|
|
console.error("no test runs after filtering");
|
|
process.exit(1);
|
|
}
|
|
|
|
// describe what we're running
|
|
const runTestNames = [...new Set(runs.map((r) => r.testInfo.name))];
|
|
const runAgentNames = [...new Set(runs.map((r) => r.agent))];
|
|
console.log(`running ${runTestNames.join(", ")} for: ${runAgentNames.join(", ")}\n`);
|
|
|
|
const cancelState: CancelState = { canceled: false, signal: null };
|
|
const results = new Map<string, ValidationResult>();
|
|
let resultsPrinted = false;
|
|
|
|
function printAndExit(validations: ValidationResult[]): void {
|
|
if (resultsPrinted) return;
|
|
resultsPrinted = true;
|
|
console.log();
|
|
for (const v of validations) {
|
|
printSingleValidation(v);
|
|
}
|
|
printResults(validations);
|
|
const allPassed = validations.every((v) => v.passed);
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
function handleCancel(signal: NodeJS.Signals): void {
|
|
if (cancelState.canceled) return;
|
|
cancelState.canceled = true;
|
|
cancelState.signal = signal;
|
|
killTrackedChildren();
|
|
|
|
const validations: ValidationResult[] = [];
|
|
for (const run of runs) {
|
|
const key = getRunKey(run.testInfo.name, run.agent);
|
|
const existing = results.get(key);
|
|
if (existing) {
|
|
validations.push(existing);
|
|
} else {
|
|
validations.push(
|
|
buildCanceledValidation({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
signal,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
printAndExit(validations);
|
|
}
|
|
|
|
setSignalHandler(handleCancel);
|
|
|
|
// run tests with limited concurrency to avoid overwhelming agent APIs
|
|
const maxConcurrency = 5;
|
|
const validations = await runWithConcurrencyLimit(runs, maxConcurrency, (run) =>
|
|
runTestForAgent({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
cancelState,
|
|
results,
|
|
})
|
|
);
|
|
|
|
if (!cancelState.canceled) {
|
|
printAndExit(validations);
|
|
}
|
|
}
|
|
|
|
// simple concurrency limiter
|
|
async function runWithConcurrencyLimit<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results: R[] = [];
|
|
const executing: Promise<void>[] = [];
|
|
|
|
for (const item of items) {
|
|
const p = fn(item).then(
|
|
(result) => {
|
|
results.push(result);
|
|
},
|
|
(err: unknown) => {
|
|
console.error("runWithConcurrencyLimit: fn rejected unexpectedly", err);
|
|
throw err;
|
|
}
|
|
);
|
|
|
|
const e = p.then(() => {
|
|
executing.splice(executing.indexOf(e), 1);
|
|
});
|
|
executing.push(e);
|
|
|
|
if (executing.length >= limit) {
|
|
await Promise.race(executing);
|
|
}
|
|
}
|
|
|
|
await Promise.all(executing);
|
|
return results;
|
|
}
|
|
|
|
main();
|