From 65c1914b1fab4ae5d85bf44707675a6097b5dc58 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 14 May 2026 08:11:10 +0200 Subject: [PATCH] test(idle-triage): lock in surfaceSelfFeedbackQueueOnIdle contract Five unit tests covering the bail-time queue notifier landed in 001740680: notify-with-pointer when candidates exist, plural/singular noun agreement, silent on empty queue, silent on non-forge basePath, no-throw when downstream notify itself crashes (bail-path safety). Locks in the contract for the partial-AC1 slice of sf-mp4rxkwb-l4baga (autonomous loop surfaces the queue at idle) without yet touching the larger remaining work (real self-feedback-triage unit type with begin/dispatch/checkpoint/complete). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/sf/auto/phases-pre-dispatch.js | 2 +- .../phases-pre-dispatch-idle-triage.test.mjs | 152 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/sf/tests/phases-pre-dispatch-idle-triage.test.mjs diff --git a/src/resources/extensions/sf/auto/phases-pre-dispatch.js b/src/resources/extensions/sf/auto/phases-pre-dispatch.js index 12edd8211..b63923616 100644 --- a/src/resources/extensions/sf/auto/phases-pre-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-pre-dispatch.js @@ -138,7 +138,7 @@ import { closeoutAndStop, generateMilestoneReport, maybeFireProductAudit, should * Best-effort: never throws, never blocks. If candidate selection fails or * the basePath isn't a forge repo, this is a no-op. */ -function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) { +export function surfaceSelfFeedbackQueueOnIdle(ctx, basePath, exitReason) { try { const candidates = selectInlineFixCandidates(basePath); if (!Array.isArray(candidates) || candidates.length === 0) return; diff --git a/src/resources/extensions/sf/tests/phases-pre-dispatch-idle-triage.test.mjs b/src/resources/extensions/sf/tests/phases-pre-dispatch-idle-triage.test.mjs new file mode 100644 index 000000000..4c3f7daf1 --- /dev/null +++ b/src/resources/extensions/sf/tests/phases-pre-dispatch-idle-triage.test.mjs @@ -0,0 +1,152 @@ +/** + * phases-pre-dispatch-idle-triage.test.mjs — verify the bail-time + * self-feedback queue notifier (sf-mp4rxkwb-l4baga partial). + * + * Scope: unit-test the surfaceSelfFeedbackQueueOnIdle helper in isolation. + * The wider runPreDispatch integration (which calls this helper from the + * "no-active-milestone" / "milestone-complete" bail paths) is not unit- + * tested here — the helper is the contract and the call sites are + * straightforward two-line additions verified by code review + the full + * SF suite passing. + */ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { surfaceSelfFeedbackQueueOnIdle } from "../auto/phases-pre-dispatch.js"; + +let tempDirs = []; + +function makeForgeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-idle-triage-test-")); + tempDirs.push(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "singularity-forge" }), + "utf-8", + ); + mkdirSync(join(dir, ".sf"), { recursive: true }); + return dir; +} + +function writeEntries(basePath, entries) { + const path = join(basePath, ".sf", "self-feedback.jsonl"); + writeFileSync( + path, + `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`, + "utf-8", + ); +} + +function entry(overrides = {}) { + return { + schemaVersion: 1, + id: `sf-test-${Math.random().toString(36).slice(2, 8)}`, + ts: new Date().toISOString(), + kind: "gap:test", + severity: "high", + blocking: false, + summary: "test entry", + source: "runtime", + ...overrides, + }; +} + +function makeFakeCtx() { + const calls = []; + return { + calls, + ctx: { + ui: { + notify: (msg, level) => { + calls.push({ msg, level }); + }, + }, + }, + }; +} + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; +}); + +describe("surfaceSelfFeedbackQueueOnIdle", () => { + test("notifies when open candidates exist with the canonical pointer to triage", () => { + const dir = makeForgeProject(); + writeEntries(dir, [ + entry({ id: "sf-a", severity: "high", summary: "first" }), + entry({ id: "sf-b", severity: "high", summary: "second" }), + ]); + const { ctx, calls } = makeFakeCtx(); + surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone"); + expect(calls).toHaveLength(1); + expect(calls[0].level).toBe("warning"); + expect(calls[0].msg).toContain("Idle (no-active-milestone)"); + expect(calls[0].msg).toContain("2 self-feedback entries"); + expect(calls[0].msg).toContain("sf headless triage --list"); + expect(calls[0].msg).toContain("sf headless triage --run"); + }); + + test("uses singular 'entry' for n=1", () => { + const dir = makeForgeProject(); + writeEntries(dir, [entry({ id: "sf-only" })]); + const { ctx, calls } = makeFakeCtx(); + surfaceSelfFeedbackQueueOnIdle(ctx, dir, "milestone-complete"); + expect(calls).toHaveLength(1); + expect(calls[0].msg).toContain("1 self-feedback entry"); + expect(calls[0].msg).toContain("milestone-complete"); + }); + + test("is silent when no open candidates remain", () => { + const dir = makeForgeProject(); + writeEntries(dir, [ + entry({ + id: "sf-resolved", + resolvedAt: "2026-05-14T00:00:00Z", + resolvedReason: "fixed", + resolvedEvidence: { kind: "agent-fix", commitSha: "abc123" }, + }), + ]); + const { ctx, calls } = makeFakeCtx(); + surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone"); + expect(calls).toHaveLength(0); + }); + + test("is silent on a non-forge basePath (best-effort no-op)", () => { + const dir = mkdtempSync(join(tmpdir(), "sf-idle-triage-nonforge-")); + tempDirs.push(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "some-other-app" }), + "utf-8", + ); + const { ctx, calls } = makeFakeCtx(); + surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone"); + expect(calls).toHaveLength(0); + }); + + test("does not throw when ctx.ui.notify itself throws", () => { + // Bail-path safety: a downstream notification failure must never + // take down the loop's clean exit. + const dir = makeForgeProject(); + writeEntries(dir, [entry({ id: "sf-x" })]); + const ctx = { + ui: { + notify: () => { + throw new Error("ui crash"); + }, + }, + }; + expect(() => + surfaceSelfFeedbackQueueOnIdle(ctx, dir, "no-active-milestone"), + ).not.toThrow(); + }); +});