Files
shockbot/agents/postRun.test.ts
T
David Blass c6a757424c Stop hook + learnings reflection via post-run loop (#515) (#548)
* add stop hook + learnings reflection to post-run loop (#515)

stop hook (#515): repo-configured script that runs after the agent
finishes. non-zero exit resumes the agent with the hook output as
guidance; persistent failure (3 attempts) marks the run failed. the
dirty-tree and stop-hook gates share a single retry loop so a fix +
push happen in one turn.

learnings reflection: per Colin, the learnings step baked into mode
checklists rarely fires — the agent stays focused on the task and the
meta-ask falls through. the post-run loop now delivers a dedicated
one-shot --continue turn asking the agent to call update_learnings if
relevant, nothing else competing for attention. reflection doesn't
consume the gate-retry budget; if it dirties the tree, the next loop
iteration catches it via the dirty-tree gate.

plumbing: Repo.stopScript column + migration, zod schema, run-context
api, AgentSettings UI. RepoSettings.stopScript threads through to
AgentRunContext and into each agent harness.

subprocess-dependent logic lives in action/agents/postRun.ts to keep
action/agents/shared.ts lean — shared.ts is reachable from
pullfrog/internal, and pulling node:child_process through it leaks
into root tsc (which uses bundler resolution, not NodeNext).

* fix: preserve successful run when reflection turn fails

The post-run reflection turn (update_learnings nudge) is a best-effort
one-shot; its failure must not flip a successful run to failed. Prior
code overwrote `result` with the reflection's return value, so a model
API error during reflection caused the whole run to be reported as
failed even though the gated work had already completed cleanly.

Now: save the pre-reflection result, and if reflection returns
`success: false`, log a warning, restore the prior success, and exit
without re-invoking the gates (re-running a freshly-green stop hook
risks a flaky false-positive failure).

Adds action/agents/postRun.test.ts covering the reflection path —
previously uncovered.

* fix: surface both stop-hook stdout and stderr to the agent

The `(stderr || stdout)` heuristic in executeStopHook dropped stdout
entirely whenever stderr had any content. Scripts that emit a benign
warning to stderr and the actionable error to stdout (common for
wrapper scripts) starved the agent of the information it needed to
fix the issue.

Now concatenate both streams (stderr first, stdout second, skipping
empty ones) before truncation. This keeps stdout's tail — usually
where summaries and totals live — intact under the 4096-char cap.

* test: lock in the core post-run retry + reflection invariants

PR #548's test plan ships four manual verification scenarios.
Convert three to vitest coverage, catching regressions on the hottest
code paths:

- persistent stop hook failure exhausts MAX_POST_RUN_RETRIES and
  surfaces as AgentResult.error with both the retry count and the
  verbatim hook output (so the GitHub-comment rendering stays
  actionable).
- every gate retry is fed the hook output as the resume prompt.
- usage aggregates across the initial run plus every retry (billing
  relies on this).
- reflection turn still fires when no stop hook is configured and the
  tree is clean.

Manual item remaining is the full UI round-trip of the settings form,
which is out of scope for unit tests.

* test: cover executeStopHook soft-fail and truncation invariants

Three paths the PR documents but previously had no regression gates:

- timeout (SPAWN_TIMEOUT_CODE) and activity-timeout
  (SPAWN_ACTIVITY_TIMEOUT_CODE) must return null, not a failure. a
  hook that times out is an infra problem; retrying with an agent
  turn risks an infinite loop.
- spawn errors (ENOENT from a typoed binary, etc.) take the same
  soft-fail path for the same reason.
- oversize hook output is truncated to the last 4096 chars with a
  "truncated" marker, keeping the tail (where summaries live) and
  protecting the 65535-char GitHub-comment budget downstream.

Regression targets — a refactor that accidentally surfaces an infra
failure as a gate failure, or blows the comment budget, will now
fail loudly in CI.

* test: cover soft-fail, no-resume, and short-circuit invariants

Three more documented behaviors that previously had no regression
gates:

- dirty-tree-only is a soft-fail: persistent uncommitted changes log
  and warn but DO NOT flip the run to failed. a regression that
  started surfacing this as AgentResult.error would break every run
  that leaves a test fixture untracked.
- canResume=false + stop hook failure still surfaces the hook failure
  as AgentResult.error. the retry budget is zero so "N retry
  attempts" is correctly omitted from the message, but the run still
  reports WHY it failed rather than silently reporting success.
- initial result with success=false short-circuits the loop: no gate
  checks, no reflection, no resume calls. the original agent error
  flows through verbatim for clean triage.

Also reset mockedSpawn in beforeEach so test state doesn't leak
between cases.

* test: lock in the reflection-dirties-tree → dirty-tree-gate path

The PR description claims: "if the reflection turn dirties the tree,
the loop picks that up on the next iteration via the normal
dirty-tree gate." There was no regression gate on this invariant.

Without it, a refactor that moved the reflection out of the retry
loop (e.g., into a one-shot post-loop call) would silently bypass
the commit-before-you-finish contract whenever the agent misbehaves
during reflection — uncommitted changes would ship as part of the
run's "success" state.

The test sequences three getGitStatus returns (clean → dirty → clean)
and asserts two resume calls: REFLECTION first, then UNCOMMITTED
CHANGES with the dirtying file in the prompt.

* fix: preserve pre-reflection task output when reflection succeeds

the reflection turn's reply ("done" or "updated learnings with N bullets")
is a meta-ask, not a task summary. before this fix, result = reflectionResult
clobbered the original task's output on the returned AgentResult, so
downstream consumers (handleAgentResult's fallback path when toolState is
empty, programmatic callers of main()) saw the reflection's trivial reply
instead of the real summary.

spread reflectionResult to inherit fields subsequent gate retries need
(e.g. the new sessionId claude emits per --resume invocation), but keep
the pre-reflection output verbatim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: fall back to reflection's output when pre-reflection output is empty

the prior fix used `??` which only fell through on null/undefined. runs
that communicate exclusively through MCP tools (e.g. report_progress) and
emit no plain text leave result.output = "", which `??` preserved as-is —
dropping the reflection's reply and leaving handleAgentResult's fallback
path with nothing to show. switch to `||` so empty-string pre-reflection
output yields the reflection's output instead of ""; non-empty task output
still wins as intended.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: drop reflection-failure-skips-hook test (over-specified control flow)

the test pinned the literal `break` in the post-reflection failure
branch with stopScript=null, asserting only that getGitStatus was
called once. that's not a behavior contract — a reasonable refactor
(e.g. `continue` to re-check gates with explicit flake guards) would
fail this test even though the new behavior would be fine. the
"does not flip a successful run to failed" test already covers the
only thing callers depend on.

* test: drop low-value mock-driven tests from postRun

- "fires the reflection turn when no stop hook is configured" — fully
  subsumed by the output-preservation test (asserts task output
  survives, which is only possible if reflection fired).
- "uses stdout alone" / "uses stderr alone" — pin format trivia
  (`filter(Boolean).join`) that LLMs ignore.
- "returns empty output (not undefined) when both streams are empty"
  — guards a TS-impossible case; every consumer uses `output || "(no output)"`.
- "returns null on activity-timeout" — duplicate of the timeout test;
  same `return null` branch with a different constant.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-05-04 19:09:42 +00:00

430 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SPAWN_TIMEOUT_CODE, SpawnTimeoutError } from "../utils/subprocess.ts";
import type { AgentResult } from "./shared.ts";
vi.mock("./shared.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("./shared.ts")>();
return {
...actual,
getGitStatus: vi.fn(() => ""),
};
});
vi.mock("../utils/subprocess.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/subprocess.ts")>();
return {
...actual,
spawn: vi.fn(),
};
});
const { runPostRunRetryLoop, executeStopHook } = await import("./postRun.ts");
const { getGitStatus } = await import("./shared.ts");
const { spawn } = await import("../utils/subprocess.ts");
const mockedGetGitStatus = vi.mocked(getGitStatus);
const mockedSpawn = vi.mocked(spawn);
const successResult = (overrides: Partial<AgentResult> = {}): AgentResult => ({
success: true,
output: "ok",
...overrides,
});
describe("runPostRunRetryLoop — reflection turn", () => {
beforeEach(() => {
mockedGetGitStatus.mockReset();
mockedGetGitStatus.mockReturnValue("");
mockedSpawn.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("does not flip a successful run to failed when reflection returns success:false", async () => {
// the reflection turn is a best-effort nudge (update_learnings). if it
// fails — e.g. the model API errors mid-turn — the underlying task has
// already completed and been gated cleanly, so the run as a whole must
// still be reported as successful.
const initial = successResult({ output: "task done" });
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue({ success: false, error: "model API transient failure" });
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
reflectionPrompt: "REFLECTION: call update_learnings if anything is worth saving",
});
expect(result.success).toBe(true);
expect(result.output).toBe("task done");
expect(result.error).toBeUndefined();
expect(resume).toHaveBeenCalledTimes(1);
expect(resume.mock.calls[0]?.[0].prompt).toMatch(/REFLECTION/);
});
it("still aggregates usage from a failed reflection turn", async () => {
// the reflection consumed tokens even if it didn't produce useful output;
// the run total must reflect that so billing/reporting stays accurate.
const initial = successResult({
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
});
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue({
success: false,
error: "model API transient failure",
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
});
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: initial.usage,
stopScript: null,
resume,
reflectionPrompt: "reflect",
});
expect(result.success).toBe(true);
expect(result.usage?.inputTokens).toBe(110);
expect(result.usage?.outputTokens).toBe(55);
});
it("falls back to the reflection's output when the pre-reflection output is empty", async () => {
// the preservation fix must only kick in when the task actually produced
// meaningful output. runs that communicate exclusively through MCP tools
// (e.g. report_progress) leave result.output = "" — using `??` here kept
// the empty string and dropped the reflection's reply, leaving the
// fallback `handleAgentResult` path with nothing to show. prefer the
// reflection's output (even a trivial "done") over no output at all.
const initial = successResult({ output: "" });
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult({ output: "done" }));
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
reflectionPrompt: "REFLECTION: consider update_learnings",
});
expect(result.success).toBe(true);
expect(result.output).toBe("done");
});
it("preserves the pre-reflection task output when a trivial reflection ('done') succeeds", async () => {
// the reflection turn is a meta-ask — its literal reply ("done" or a
// short "updated learnings with N bullets") is not the task summary the
// caller wants to see. before this fix, `result = reflectionResult`
// clobbered the task's output on the returned AgentResult, so downstream
// consumers (handleAgentResult's fallback path when toolState is empty,
// programmatic callers of main()) saw "done" instead of the real
// summary. assert the task's output survives a successful reflection.
const initial = successResult({ output: "Implemented feature X; tests pass; pushed PR #42" });
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult({ output: "done" }));
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
reflectionPrompt: "REFLECTION: consider update_learnings",
});
expect(result.success).toBe(true);
expect(result.output).toBe("Implemented feature X; tests pass; pushed PR #42");
});
it("skips reflection entirely when canResume returns false", async () => {
const initial = successResult();
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult());
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
canResume: () => false,
reflectionPrompt: "reflect",
});
expect(result.success).toBe(true);
expect(resume).not.toHaveBeenCalled();
});
it("catches a reflection turn that dirties the tree via the dirty-tree gate on the next iteration", async () => {
// PR claims: "if the reflection turn dirties the tree, the loop picks
// that up on the next iteration via the normal dirty-tree gate." lock
// it in — without this invariant the reflection prompt could bypass the
// commit-before-you-finish contract whenever the agent misbehaves.
//
// three getGitStatus calls in sequence:
// 1. clean (triggers reflection)
// 2. reflection left the tree dirty
// 3. retry committed the changes — now clean, loop exits
mockedGetGitStatus
.mockReturnValueOnce("")
.mockReturnValueOnce(" M scratch/notes.md")
.mockReturnValueOnce("");
const initial = successResult();
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult({ output: "resumed" }));
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
reflectionPrompt: "REFLECTION: consider update_learnings",
});
expect(result.success).toBe(true);
// call 0: reflection; call 1: dirty-tree retry
expect(resume).toHaveBeenCalledTimes(2);
expect(resume.mock.calls[0]?.[0].prompt).toContain("REFLECTION");
expect(resume.mock.calls[1]?.[0].prompt).toContain("UNCOMMITTED CHANGES");
expect(resume.mock.calls[1]?.[0].prompt).toContain("scratch/notes.md");
});
it("surfaces a persistent stop hook failure as AgentResult.error after MAX_POST_RUN_RETRIES", async () => {
// PR test plan item #1: "confirm the agent is resumed with the hook
// output and the run fails after 3 attempts if never resolved."
//
// stop the hook from passing on every invocation, have `resume` always
// return success (the agent tried but couldn't fix the issue), and
// verify: (a) the loop exhausts all retries, (b) the final result is
// success=false, (c) the error mentions the retry count and the hook
// output verbatim so the GitHub comment surfaces what actually failed.
const hookFailure = {
stdout: "lint: 3 issues in src/foo.ts",
stderr: "",
exitCode: 7,
durationMs: 5,
};
mockedSpawn.mockResolvedValue(hookFailure);
const initial = successResult({ output: "agent done" });
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult({ output: "retry done" }));
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: "pnpm lint",
resume,
reflectionPrompt: undefined,
});
expect(result.success).toBe(false);
expect(result.error).toContain("stop hook failed");
expect(result.error).toContain("exit code 7");
expect(result.error).toContain("3 retry attempts");
expect(result.error).toContain("lint: 3 issues in src/foo.ts");
// each retry feeds the hook output back into the agent as the resume prompt
expect(resume).toHaveBeenCalledTimes(3);
for (const call of resume.mock.calls) {
expect(call[0].prompt).toContain("STOP HOOK FAILED");
expect(call[0].prompt).toContain("lint: 3 issues in src/foo.ts");
}
});
it("treats a persistently dirty tree (no stop hook failure) as a soft-fail", async () => {
// the PR documents: "dirty-tree-only failures preserve prior behavior:
// they're logged but don't fail the run." a regression that started
// surfacing dirty-tree as AgentResult.error would make every run that
// leaves untracked test fixtures around fail spuriously. guard it.
mockedGetGitStatus.mockReturnValue(" M src/foo.ts");
const initial = successResult();
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult({ output: "tried but tree still dirty" }));
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: null,
resume,
});
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
// retries were attempted (the loop fed the dirty-tree prompt back to the agent)
expect(resume).toHaveBeenCalledTimes(3);
for (const call of resume.mock.calls) {
expect(call[0].prompt).toContain("UNCOMMITTED CHANGES");
}
});
it("surfaces a stop hook failure even when canResume is false (no retry budget, still fails the run)", async () => {
// the retry loop is best-effort. when canResume says no (e.g. claude
// without a sessionId), we still need the failure gate to fire so the
// user sees WHY the run failed instead of an opaque success. covers the
// "checks still ran even if we can't resume" comment in postRun.ts.
mockedSpawn.mockResolvedValue({
stdout: "typecheck: 2 errors",
stderr: "",
exitCode: 1,
durationMs: 1,
});
const initial = successResult();
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult());
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: "pnpm typecheck",
resume,
canResume: () => false,
});
expect(result.success).toBe(false);
expect(result.error).toContain("stop hook failed");
expect(result.error).toContain("typecheck: 2 errors");
// no retries were attempted because canResume said no — error lists no
// retry count (that would be a lie).
expect(result.error).not.toContain("retry attempt");
expect(resume).not.toHaveBeenCalled();
});
it("short-circuits the loop when the initial result is already failed", async () => {
// if the agent already failed (timeout, model error) there's no point
// running gates or a reflection — the run is toast. preserve the original
// error verbatim so triage is straightforward.
const initial: AgentResult = {
success: false,
error: "agent died mid-turn",
output: "partial",
};
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue(successResult());
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: undefined,
stopScript: "pnpm lint",
resume,
reflectionPrompt: "reflect",
});
expect(result.success).toBe(false);
expect(result.error).toBe("agent died mid-turn");
expect(resume).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockedGetGitStatus).not.toHaveBeenCalled();
});
it("aggregates usage across every gate retry", async () => {
// billing/reporting rely on the usage summary reflecting the full run,
// not just the final retry's slice. regression gate.
mockedSpawn.mockResolvedValue({
stdout: "fail",
stderr: "",
exitCode: 1,
durationMs: 1,
});
const initial = successResult({
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
});
const resume = vi
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
.mockResolvedValue({
success: true,
output: "retry",
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
});
const result = await runPostRunRetryLoop({
initialResult: initial,
initialUsage: initial.usage,
stopScript: "flaky",
resume,
});
// 100 initial + 10 * 3 retries = 130
expect(result.usage?.inputTokens).toBe(130);
expect(result.usage?.outputTokens).toBe(65);
});
});
describe("executeStopHook — output capture", () => {
beforeEach(() => {
mockedSpawn.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("includes both stdout and stderr in the failure output when both are populated", async () => {
// hooks that wrap other tools commonly emit a benign warning to stderr
// (e.g. "config file not found, using defaults") and the actionable error
// to stdout. a `(stderr || stdout)` heuristic drops stdout entirely
// whenever stderr is non-empty, starving the agent of the information it
// needs to fix the issue.
mockedSpawn.mockResolvedValue({
stdout: "ERROR: lint check failed at path/to/file.ts:42",
stderr: "Warning: config file not found, using defaults",
exitCode: 1,
durationMs: 5,
});
const failure = await executeStopHook("run-lint");
expect(failure).not.toBeNull();
expect(failure?.output).toContain("ERROR: lint check failed at path/to/file.ts:42");
expect(failure?.output).toContain("Warning: config file not found, using defaults");
});
it("returns null (treated as passed) when spawn throws a timeout", async () => {
// infra-level failures can't be fixed by the agent. surfacing them as a
// hook failure would put the loop into a retry cycle that never
// terminates. soft-fail and let the run succeed.
mockedSpawn.mockRejectedValue(
new SpawnTimeoutError("hook exceeded 10 minutes", SPAWN_TIMEOUT_CODE)
);
const failure = await executeStopHook("slow-hook");
expect(failure).toBeNull();
});
it("returns null (treated as passed) on spawn ENOENT (command not found)", async () => {
// if the user misconfigures the hook (wrong binary, typo), the spawn
// itself throws. same rationale as timeouts: soft-fail, don't retry.
mockedSpawn.mockRejectedValue(
Object.assign(new Error("spawn nosuchbin ENOENT"), { code: "ENOENT" })
);
const failure = await executeStopHook("nosuchbin");
expect(failure).toBeNull();
});
it("truncates oversize output, keeping the tail", async () => {
// the error is embedded in AgentResult.error and flows into GitHub
// comments (65535-char cap). the 4096-char truncation is our guardrail;
// lock it in so a well-meaning refactor can't blow the comment budget.
const longTail = "LAST_LINE_IS_ACTIONABLE";
const longOutput = "x".repeat(10_000) + longTail;
mockedSpawn.mockResolvedValue({
stdout: longOutput,
stderr: "",
exitCode: 1,
durationMs: 1,
});
const failure = await executeStopHook("noisy");
expect(failure?.output).toContain(longTail);
expect(failure?.output).toContain("truncated");
expect(failure?.output.length).toBeLessThan(longOutput.length);
});
});