diff --git a/docs/operations/agent-surface-eval.md b/docs/operations/agent-surface-eval.md index 9e39e21a09..efd85fa5ad 100644 --- a/docs/operations/agent-surface-eval.md +++ b/docs/operations/agent-surface-eval.md @@ -164,6 +164,65 @@ The manual tasks need connected hosts and a person who approves or finishes sign runner includes them only with `--include-manual`. D8's historical provider-sync verdict requires those tasks; this does not gate `goat_screen_run`. +## Replaying tool-use mistakes + +The separate [replay cases](../../scripts/agent-surface/eval/replay-cases.ts) cover review +rounds, async delegation, managed worktrees, PR ownership, thread links, and HTML preview. +They use invented histories adapted from upstream's tool-use regressions. They do not change +the paired campaign task set or count toward its gate. + +Export a case with this checkout's GOAT instructions and the tools its thread listing serves: + +```sh +node scripts/agent-surface/eval/replay-run.ts --case async-delegate-ends-turn --listing core --context +``` + +Repeat with `--listing full` to compare the two surfaces. Context generation uses the existing +inert catalog fixture; no installed GOAT server, credentials, provider, or captured harness +prompt is needed. The output is harness-independent GOAT context, not a complete Claude or +Codex request. An external harness supplies its native tools, honors the case's role and prior +turns, and records the new continuation. This command does not run that harness or simulate +future tool results. + +Save the continuation as JSON with `version`, `caseId`, and `listing` from the exported context, +plus `calls`, `text`, and `stopReason`. Calls contain `tool`, object `input`, and optional +`isError` (set it to `true` for rejected calls). Record executed nested code-mode calls in order; +the grader does not execute or inspect JavaScript source. Include only the new continuation, +because setup calls cannot satisfy the case's requirements. For example: + +```json +{ + "version": 3, + "caseId": "async-delegate-ends-turn", + "listing": "core", + "calls": [ + { + "tool": "mcp__goat__goat_call", + "input": { + "path": "task.create", + "args": { "task": "Compare the cache designs", "mode": "async" } + } + } + ], + "text": "The research task is running; its result will arrive automatically.", + "stopReason": "end_turn" +} +``` + +Grade it without calling a model or executing any replayed action: + +```sh +node scripts/agent-surface/eval/replay-run.ts --case async-delegate-ends-turn --listing core --trace ../continuation.json +``` + +Exit 1 means a rule failed. `max_steps` and `error` stop reasons always fail. The grader checks +required and forbidden calls, partial arguments, ordering, call counts, shell patterns and +answer text. It resolves GOAT names through the current registry, accepts `goat_call` dispatch +in either listing, and rejects unknown paths or direct calls outside the selected listing. +These are behavioral checks on the supplied trace, not proof that its arguments passed the +server's schemas or that the requested work happened. Keep real transcripts and credentials +out of committed replay data. Changing a case or grading rule raises `REPLAY_VERSION`. + ## Not yet possible - `goat_eval` is unbuilt. Its candidate arm remains historical; this runner does diff --git a/knip.jsonc b/knip.jsonc index 54d9d6e6ed..c81ad67643 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -25,6 +25,7 @@ // These fixture scripts are injected into a browser page by path. "fixtures/*/*.js", "knip-schemas.ts", + "agent-surface/eval/replay-run.ts", "mobile-native-client.ts", "setup-worktree.ts", "worktree-add.ts", diff --git a/scripts/agent-surface/eval/replay-cases.ts b/scripts/agent-surface/eval/replay-cases.ts new file mode 100644 index 0000000000..64ccbb4af5 --- /dev/null +++ b/scripts/agent-surface/eval/replay-cases.ts @@ -0,0 +1,151 @@ +import type { ReplayCase } from "./replay.ts"; + +export const REPLAY_VERSION = 3; +const pr = "https://github.com/example/replay-fixture/pull/124"; +const threadId = "11111111-1111-4111-8111-111111111111"; + +export const replayCases: ReadonlyArray = [ + { + id: "review-round-creates-new-task", + upstreamId: "review-round-uses-thread-send", + role: "top", + experience: + "A second review round continued the old child thread and lost its task relationship.", + priorTurns: [ + { + role: "user", + text: "Get a delegated review of the cache migration. Repeat after fixing findings.", + }, + { + role: "assistant", + text: "Round one found that the cache migration discards version-four entries. That is now fixed.", + calls: [ + { + tool: "goat_task_read", + input: { taskId: "review-1" }, + result: { + taskId: "review-1", + childThreadId: threadId, + status: "completed", + summary: "The cache migration discards version-four entries.", + }, + }, + ], + }, + ], + prompt: + "Run round two with a new delegated review task. Include the cache finding and the fix.", + expect: { + calls: [{ tool: "task.create", args: { role: "review", task: "~cache" } }], + notCalls: ["thread.send"], + }, + }, + { + id: "async-delegate-ends-turn", + upstreamId: "async-delegate-then-end-turn", + role: "top", + experience: "An agent polled after starting async work even though GOAT delivers completion.", + priorTurns: [], + prompt: + "Start a GOAT delegated research task to compare the two cache designs, then wait for its automatic delivery.", + expect: { + calls: [{ tool: "task.create", args: { mode: "async" } }], + notCalls: ["task.read", "thread.wait"], + endsTurnAfter: "task.create", + shellMustNot: ["\\bsleep\\b", "inotifywait|fswatch"], + }, + }, + { + id: "delegate-uses-managed-worktree", + upstreamId: "worktree-add-for-parallel-pr-delegates", + role: "top", + experience: "A parent created an unmanaged worktree for a child implementation lane.", + priorTurns: [], + prompt: + "Delegate the cache fix to a GOAT child with its own isolated checkout. GOAT should manage the checkout.", + expect: { + calls: [{ tool: "task.create", args: { isolation: "worktree" } }], + callsMustNot: [ + { tool: "task.create", args: { task: "~git worktree add" } }, + { tool: "task.create", args: { task: "~git clone" } }, + ], + notCalls: ["thread.launch"], + shellMustNot: ["git\\s+worktree\\s+add", "git\\s+clone"], + }, + }, + { + id: "child-links-without-watching", + upstreamId: "child-pushes-existing-pr-links", + role: "delegated-child", + experience: + "A delegated implementation child watched checks instead of reporting to its parent.", + priorTurns: [ + { + role: "assistant", + text: `The authorized fix was pushed to ${pr}, head abcdef1234567890. No checks have run yet.`, + }, + ], + prompt: + "Link the PR you pushed and report its URL, head and check state to your parent. Do not wait for CI.", + expect: { + calls: [{ tool: "pull_request.link", args: { url: pr } }], + notCalls: ["pull_request.watch"], + shellMustNot: [ + "\\bsleep\\b", + "gh pr checks[^\\n]*--watch", + "gh run watch", + "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", + "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", + "pr-wait", + ], + textMust: ["https://github\\.com/example/replay-fixture/pull/124", "abcdef1234567890"], + }, + }, + { + id: "review-only-does-not-link-pr", + upstreamId: "review-child-links-reviewed-pr", + role: "delegated-child", + experience: "A reviewer linked a PR it only read, making the thread appear to own that work.", + priorTurns: [ + { + role: "assistant", + text: `I read the diff of ${pr}. The cache key includes the version and the migration preserves entries. No findings.`, + }, + ], + prompt: + "Finish your read-only review with a verdict. Do not push or take ownership of this PR.", + expect: { + notCalls: ["pull_request.link", "pull_request.watch", "pull_request.list"], + shellMustNot: ["git\\s+push", "gh pr (create|merge)"], + textMust: ["[Nn]o findings|PASS"], + }, + }, + { + id: "thread-read-returns-link", + upstreamId: "thread-mention-without-link-after-read", + role: "top", + experience: "An agent mentioned a thread it read without a clickable GOAT thread link.", + priorTurns: [], + prompt: `Read the latest messages of thread ${threadId}, summarize them, and link the thread. Read at most twenty items.`, + expect: { + calls: [{ tool: "thread.read", args: { threadId, limit: "<=20" } }], + textMust: [`goat-thread://v1/${threadId}`], + }, + }, + { + id: "preview-before-render", + upstreamId: "render-without-html-preview", + role: "top", + experience: + "An agent published an inline visualization without first checking the HTML preview.", + priorTurns: [], + prompt: + "Make an inline HTML diagram of the cache flow. Check the preview before publishing it in this conversation.", + expect: { + calls: [{ tool: "html.preview" }, { tool: "html.render" }], + order: ["html.preview", "html.render"], + maxCalls: { "html.render": 1 }, + shellMustNot: ["http\\.server", "npx (serve|http-server)"], + }, + }, +]; diff --git a/scripts/agent-surface/eval/replay-run.ts b/scripts/agent-surface/eval/replay-run.ts new file mode 100644 index 0000000000..ef3461f2fc --- /dev/null +++ b/scripts/agent-surface/eval/replay-run.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - a plain Node script that reads offline replay context and trace files; no Effect runtime. +import * as NodeFS from "node:fs"; +import * as NodeUtil from "node:util"; +import { loadServedCatalog } from "../servedCatalog.ts"; +import { REPLAY_VERSION, replayCases } from "./replay-cases.ts"; +import { decodeReplayRun, gradeReplay } from "./replay.ts"; + +const { values } = NodeUtil.parseArgs({ + options: { + case: { type: "string" }, + listing: { type: "string", default: "core" }, + context: { type: "boolean", default: false }, + trace: { type: "string" }, + }, +}); +const testCase = replayCases.find((entry) => entry.id === values.case); +if (!testCase) + throw new Error("--case must name one of: " + replayCases.map((entry) => entry.id).join(", ")); +const listing = values.listing; +if (listing !== "core" && listing !== "full") throw new Error("--listing must be core or full"); +if (values.context === Boolean(values.trace)) + throw new Error("Choose --context or --trace "); + +const catalog = await loadServedCatalog(); +try { + const tools = await catalog.listedAsThread(listing); + if (values.context) { + // Loaded at runtime: the scripts project does not compile provider-core. + const instructions = (await import( + new URL( + "../../../packages/provider-core/src/server/orchestrationInstructions.ts", + import.meta.url, + ).href + )) as { + readonly goatOrchestrationSystemPrompt: (hasGoatMcp: boolean) => string; + readonly GOAT_BROWSER_TOOL_INSTRUCTIONS: string; + }; + console.log( + JSON.stringify( + { + version: REPLAY_VERSION, + caseId: testCase.id, + listing, + role: testCase.role, + instructions: + instructions.goatOrchestrationSystemPrompt(true) + + instructions.GOAT_BROWSER_TOOL_INSTRUCTIONS, + tools, + priorTurns: testCase.priorTurns, + prompt: testCase.prompt, + }, + null, + 2, + ), + ); + } else { + const tracePath = values.trace; + if (!tracePath) throw new Error("--trace needs a file path"); + const raw: unknown = JSON.parse(NodeFS.readFileSync(tracePath, "utf8")); + if ( + typeof raw !== "object" || + raw === null || + !("version" in raw) || + raw.version !== REPLAY_VERSION || + !("caseId" in raw) || + raw.caseId !== testCase.id || + !("listing" in raw) || + raw.listing !== listing + ) + throw new Error("Trace version, caseId and listing must match the replay context"); + const checks = gradeReplay( + testCase.expect, + decodeReplayRun(raw), + tools.map((tool) => tool.name), + ); + const pass = checks.every((check) => check.pass); + console.log( + JSON.stringify( + { version: REPLAY_VERSION, caseId: testCase.id, listing, pass, checks }, + null, + 2, + ), + ); + if (!pass) process.exitCode = 1; + } +} finally { + await catalog.dispose(); +} diff --git a/scripts/agent-surface/eval/replay.test.ts b/scripts/agent-surface/eval/replay.test.ts new file mode 100644 index 0000000000..e7f9027cd2 --- /dev/null +++ b/scripts/agent-surface/eval/replay.test.ts @@ -0,0 +1,278 @@ +import { expect, it } from "vite-plus/test"; +import { loadServedCatalog } from "../servedCatalog.ts"; +import type { JsonObject } from "./core.ts"; +import { replayCases } from "./replay-cases.ts"; +import { decodeReplayRun, gradeReplay, type ReplayCall, type ReplayRun } from "./replay.ts"; + +const call = (tool: string, input: JsonObject = {}): ReplayCall => ({ tool, input }); +const dispatch = (path: string, args: JsonObject = {}): ReplayCall => + call("mcp__goat__goat_call", { path, args }); +const run = (calls: ReadonlyArray, text = ""): ReplayRun => ({ + calls, + text, + stopReason: "end_turn", +}); +const failed = ( + expected: Parameters[0], + sample: ReplayRun, + listed = ["goat_call"], +) => + gradeReplay(expected, sample, listed) + .filter((check) => !check.pass) + .map((check) => check.check); +const pr = "https://github.com/example/replay-fixture/pull/124"; +const threadId = "11111111-1111-4111-8111-111111111111"; + +const continuations: Readonly< + Record +> = { + "review-round-creates-new-task": { + good: run([ + call("goat_task_create", { + role: "review", + task: "Review the cache fix; the old cache migration discarded v4 entries.", + }), + ]), + bad: run([call("goat_thread_send", { threadId, message: "Review round two" })]), + }, + "async-delegate-ends-turn": { + good: run([call("goat_task_create", { task: "Compare cache designs", mode: "async" })]), + bad: run([ + call("goat_task_create", { task: "Compare cache designs", mode: "async" }), + call("goat_task_read", { taskId: "task-1" }), + call("functions.exec_command", { cmd: "sleep 60" }), + ]), + }, + "delegate-uses-managed-worktree": { + good: run([call("goat_task_create", { task: "Fix the cache", isolation: "worktree" })]), + bad: run([ + call("Bash", { command: "git worktree add /tmp/cache-lane" }), + call("goat_task_create", { task: "Fix the cache" }), + ]), + }, + "child-links-without-watching": { + good: run( + [call("goat_pull_request_link", { url: pr })], + `PR ${pr}, head abcdef1234567890, checks not run`, + ), + bad: run([ + call("goat_pull_request_link", { url: pr }), + call("goat_pull_request_watch", { url: pr }), + ]), + }, + "review-only-does-not-link-pr": { + good: run([], "PASS: no findings."), + bad: run([call("goat_pull_request_link", { url: pr })], "PASS: no findings."), + }, + "thread-read-returns-link": { + good: run( + [call("goat_thread_read", { threadId, limit: 20 })], + `[Thread](goat-thread://v1/${threadId})`, + ), + bad: run([call("goat_thread_read", { threadId, limit: 21 })], "The thread is done."), + }, + "preview-before-render": { + good: run([ + call("goat_html_preview", { html: "

Cache

" }), + call("goat_html_render", { html: "

Cache

" }), + ]), + bad: run([ + call("goat_html_render", { html: "

Cache

" }), + call("goat_html_preview", { html: "

Cache

" }), + ]), + }, +}; + +it("grades each replay's good and mistaken continuation against the served full and core listings", async () => { + const catalog = await loadServedCatalog(); + try { + for (const mode of ["full", "core"] as const) { + const tools = await catalog.listedAsThread(mode); + const listed = tools.map((tool) => tool.name); + for (const testCase of replayCases) { + const samples = continuations[testCase.id]; + expect(samples, testCase.id).toBeDefined(); + if (!samples) throw new Error("Missing continuation for " + testCase.id); + const throughDispatcher = (sample: ReplayRun): ReplayRun => ({ + ...sample, + calls: sample.calls.map((entry) => { + const path = entry.tool.startsWith("goat_") + ? entry.tool.slice(5).replace(/_([^_]+)$/, ".$1") + : null; + return path ? dispatch(path, entry.input) : entry; + }), + }); + const good = mode === "core" ? throughDispatcher(samples.good) : samples.good; + const bad = mode === "core" ? throughDispatcher(samples.bad) : samples.bad; + expect(failed(testCase.expect, good, listed), `${mode}: ${testCase.id}`).toEqual([]); + expect(failed(testCase.expect, bad, listed), `${mode}: ${testCase.id}`).not.toEqual([]); + } + } + } finally { + await catalog.dispose(); + } +}); + +it("does not let an unlisted, unknown, failed or foreign-server call satisfy a GOAT requirement", () => { + const expected = { calls: [{ tool: "task.create" }] }; + expect(failed(expected, run([call("goat_task_create")]))).toContain("listed goat_task_create"); + expect(failed(expected, run([dispatch("task.typo")]))).toContain("dispatch task.typo"); + expect(failed(expected, run([{ ...dispatch("task.create"), isError: true }]))).toContain( + "calls task.create", + ); + expect(failed(expected, run([call("mcp__other__goat_task_create")]))).toContain( + "calls task.create", + ); + expect(failed({}, run([call("mcp__goat__invented_tool")]))).toContain( + "unknown GOAT tool mcp__goat__invented_tool", + ); + expect(failed(expected, run([call("goat_call", { path: "task.create", args: [] })]))).toContain( + "dispatch task.create", + ); +}); + +it.each([ + "Bash", + "functions.Bash", + "exec_command", + "functions.exec_command", + "Monitor", + "functions.Monitor", +])("rejects forbidden %s commands even when every other replay requirement passes", (tool) => { + const review = replayCases.find((entry) => entry.id === "review-only-does-not-link-pr"); + const managed = replayCases.find((entry) => entry.id === "delegate-uses-managed-worktree"); + if (!review || !managed) throw new Error("Missing shell regression cases"); + const commandInput = (command: string): JsonObject => + tool.endsWith("exec_command") ? { cmd: command } : { command }; + expect( + failed(review.expect, run([call(tool, commandInput("git status"))], "PASS: no findings.")), + ).toEqual([]); + expect( + failed( + review.expect, + run([call(tool, commandInput("git push origin HEAD"))], "PASS: no findings."), + ), + ).toEqual(["shellMustNot /git\\s+push/"]); + for (const command of ["git worktree add /tmp/cache-lane", "git clone ./cache /tmp/cache-lane"]) + expect( + failed( + managed.expect, + run([ + dispatch("task.create", { task: "Fix the cache", isolation: "worktree" }), + call(tool, commandInput(command)), + ]), + ), + ).toEqual([ + command.startsWith("git clone") + ? "shellMustNot /git\\s+clone/" + : "shellMustNot /git\\s+worktree\\s+add/", + ]); +}); + +it.each([ + ["until gh pr checks 124; do :; done", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b"], + ["until gh run view 123; do :; done", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b"], + [ + "until gh api repos/example/replay-fixture/actions/runs; do :; done", + "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", + ], + [ + "for attempt in 1 2 3; do gh pr checks 124; done", + "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", + ], + ["node scripts/pr-wait.mjs 124", "pr-wait"], +])("rejects child CI polling via %s while allowing a one-shot check read", (command, pattern) => { + const child = replayCases.find((entry) => entry.id === "child-links-without-watching"); + if (!child) throw new Error("Missing child-PR regression case"); + const continuation = (command: string) => + run( + [dispatch("pull_request.link", { url: pr }), call("Bash", { command })], + `PR ${pr}, head abcdef1234567890, checks pending, reviews not requested`, + ); + expect(failed(child.expect, continuation("gh pr checks 124"))).toEqual([]); + expect(failed(child.expect, continuation(command))).toEqual([`shellMustNot /${pattern}/`]); +}); + +it("rejects asking an isolated child to create an unmanaged worktree or clone", () => { + const managed = replayCases.find((entry) => entry.id === "delegate-uses-managed-worktree"); + if (!managed) throw new Error("Missing managed-worktree regression case"); + for (const tool of ["goat_task_create", "mcp__goat__goat_call"]) { + const delegate = (task: string) => { + const args = { isolation: "worktree", task }; + return tool === "goat_task_create" ? call(tool, args) : dispatch("task.create", args); + }; + const listed = ["goat_call", "goat_task_create"]; + expect(failed(managed.expect, run([delegate("Fix the cache")]), listed)).toEqual([]); + for (const command of [ + "git worktree add /tmp/cache-child", + "git clone ./cache /tmp/cache-child", + ]) + expect( + failed( + managed.expect, + run([delegate(`Fix the cache; first run ${command} and work there`)]), + listed, + ), + ).toEqual(["callsMustNot task.create"]); + } +}); + +it("matches provider options in either wire form and refuses string numbers or missing arguments", () => { + const expected = { + calls: [ + { + tool: "task.create", + args: { "target.options.reasoningEffort": "high", timeoutMs: ">=60000" }, + }, + ], + }; + for (const options of [{ reasoningEffort: "high" }, [{ id: "reasoningEffort", value: "high" }]]) { + expect( + failed(expected, run([dispatch("task.create", { target: { options }, timeoutMs: 60000 })])), + ).toEqual([]); + expect( + failed(expected, run([dispatch("task.create", { target: { options }, timeoutMs: "60000" })])), + ).toContain("calls task.create"); + } + expect(failed(expected, run([dispatch("task.create")]))).toContain("calls task.create"); +}); + +it("requires ordered repeated calls, counts duplicates and rejects unfinished continuations", () => { + const expected = { + order: ["html.preview", "html.render", "html.preview"], + maxCalls: { "html.render": 1 }, + }; + const calls = [dispatch("html.preview"), dispatch("html.render"), dispatch("html.preview")]; + expect(failed(expected, run(calls))).toEqual([]); + expect(failed(expected, run(calls.slice(0, 2)))).toContain( + "order html.preview > html.render > html.preview", + ); + expect(failed(expected, run([...calls, dispatch("html.render")]))).toContain( + "maxCalls html.render <= 1", + ); + expect( + failed( + { endsTurnAfter: "task.create" }, + { ...run([dispatch("task.create")]), stopReason: "max_steps" }, + ), + ).toEqual(["endsTurnAfter task.create", "completed"]); +}); + +it("refuses malformed traces instead of reporting a pass", () => { + for (const raw of [ + null, + {}, + { calls: [], text: "", stopReason: "unknown" }, + { calls: [], text: "", stopReason: ["end_turn"] }, + { calls: [{ tool: "goat_call", input: null }], text: "", stopReason: "end_turn" }, + { + calls: [{ tool: "goat_call", input: {}, isError: "false" }], + text: "", + stopReason: "end_turn", + }, + ]) + expect(() => decodeReplayRun(raw)).toThrow(); + expect(decodeReplayRun(run([dispatch("thread.read", { threadId })]))).toEqual( + run([dispatch("thread.read", { threadId })]), + ); +}); diff --git a/scripts/agent-surface/eval/replay.ts b/scripts/agent-surface/eval/replay.ts new file mode 100644 index 0000000000..605abe9eb7 --- /dev/null +++ b/scripts/agent-surface/eval/replay.ts @@ -0,0 +1,190 @@ +import { SHIPPED_TOOL_NAMES } from "../listedNames.ts"; +import type { Json, JsonObject } from "./core.ts"; +import { goatToolOf } from "./core.ts"; + +export interface ReplayCall { + readonly tool: string; + readonly input: JsonObject; + readonly isError?: boolean; +} + +export interface ReplayRun { + readonly calls: ReadonlyArray; + readonly text: string; + readonly stopReason: "end_turn" | "max_steps" | "error"; +} + +interface ExpectedCall { + readonly tool: string; + readonly args?: JsonObject; +} + +export interface ReplayExpectation { + readonly calls?: ReadonlyArray; + readonly callsMustNot?: ReadonlyArray; + readonly notCalls?: ReadonlyArray; + readonly order?: ReadonlyArray; + readonly maxCalls?: Readonly>; + readonly endsTurnAfter?: string; + readonly shellMustNot?: ReadonlyArray; + readonly textMust?: ReadonlyArray; +} + +export interface ReplayCase { + readonly id: string; + readonly upstreamId: string; + readonly role: "top" | "delegated-child"; + readonly experience: string; + readonly priorTurns: ReadonlyArray<{ + readonly role: "user" | "assistant"; + readonly text: string; + readonly calls?: ReadonlyArray; + }>; + readonly prompt: string; + readonly expect: ReplayExpectation; +} + +const isObject = (value: unknown): value is JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value); +const canonical = (name: string) => SHIPPED_TOOL_NAMES.get(name) ?? name; +const bareName = (name: string) => goatToolOf(name) ?? name; +const shellTool = (name: string) => /^(?:functions\.)?(?:exec_command|Bash|Monitor)$/.test(name); + +/** Partial arguments: dotted paths, ~substring, and numeric >= / <= bounds. */ +const matches = (expected: Json, actual: Json | undefined): boolean => { + if (typeof expected === "string") { + const bound = /^(>=|<=)(-?\d+(?:\.\d+)?)$/.exec(expected); + if (bound) + return ( + typeof actual === "number" && + (bound[1] === ">=" ? actual >= Number(bound[2]) : actual <= Number(bound[2])) + ); + if (expected.startsWith("~")) + return ( + actual !== undefined && + JSON.stringify(actual).toLowerCase().includes(expected.slice(1).toLowerCase()) + ); + } + if (!isObject(expected)) return expected === actual; + return Object.entries(expected).every(([path, value]) => { + const at = path.split(".").reduce((current, key) => { + // Provider options have both object and [{id, value}] wire forms. + if (Array.isArray(current)) { + const option = current.find((item) => isObject(item) && item["id"] === key); + return isObject(option) ? option["value"] : undefined; + } + return isObject(current) ? current[key] : undefined; + }, actual); + return matches(value, at); + }); +}; + +/** Judges only the continuation; setup calls are context, never evidence of success. */ +export const gradeReplay = ( + expected: ReplayExpectation, + run: ReplayRun, + listedNames: ReadonlyArray, +) => { + const checks: Array<{ readonly check: string; readonly pass: boolean }> = []; + const add = (check: string, pass: boolean) => checks.push({ check, pass }); + const listed = new Set(listedNames); + const calls = run.calls.flatMap((call) => { + const bare = bareName(call.tool); + if (!SHIPPED_TOOL_NAMES.has(bare)) { + if (goatToolOf(call.tool) !== null || bare.startsWith("goat_")) + add(`unknown GOAT tool ${call.tool}`, false); + return [call]; + } + const admitted = listed.has(bare); + add(`listed ${bare}`, admitted); + if (!admitted) return []; + if (canonical(bare) !== canonical("surface.call")) return [{ ...call, tool: canonical(bare) }]; + const path = call.input["path"]; + const args = call.input["args"] ?? {}; + const known = typeof path === "string" && SHIPPED_TOOL_NAMES.has(path); + add(`dispatch ${String(path)}`, known && isObject(args)); + if (!known || !isObject(args)) return []; + return [{ ...call, tool: canonical(path), input: args }]; + }); + const count = (tool: string) => calls.filter((call) => call.tool === canonical(tool)).length; + for (const wanted of expected.calls ?? []) + add( + `calls ${wanted.tool}`, + calls.some( + (call) => + !call.isError && + call.tool === canonical(wanted.tool) && + matches(wanted.args ?? {}, call.input), + ), + ); + for (const forbidden of expected.callsMustNot ?? []) + add( + `callsMustNot ${forbidden.tool}`, + !calls.some( + (call) => + call.tool === canonical(forbidden.tool) && matches(forbidden.args ?? {}, call.input), + ), + ); + for (const tool of expected.notCalls ?? []) add(`notCalls ${tool}`, count(tool) === 0); + if (expected.order) { + let previous = -1; + const ordered = expected.order.every((tool) => { + const next = calls.findIndex( + (call, index) => index > previous && !call.isError && call.tool === canonical(tool), + ); + previous = next; + return next !== -1; + }); + add(`order ${expected.order.join(" > ")}`, ordered); + } + for (const [tool, max] of Object.entries(expected.maxCalls ?? {})) + add(`maxCalls ${tool} <= ${max}`, count(tool) <= max); + if (expected.endsTurnAfter) + add( + `endsTurnAfter ${expected.endsTurnAfter}`, + run.stopReason === "end_turn" && + calls.at(-1)?.tool === canonical(expected.endsTurnAfter) && + !calls.at(-1)?.isError, + ); + const commands = run.calls + .filter((call) => shellTool(call.tool)) + .map((call) => String(call.input["cmd"] ?? call.input["command"] ?? "")); + for (const pattern of expected.shellMustNot ?? []) + add( + `shellMustNot /${pattern}/`, + !commands.some((command) => new RegExp(pattern).test(command)), + ); + for (const pattern of expected.textMust ?? []) + add(`textMust /${pattern}/`, new RegExp(pattern).test(run.text)); + add("completed", run.stopReason === "end_turn"); + return checks; +}; + +export const decodeReplayRun = (raw: unknown): ReplayRun => { + if ( + !isObject(raw) || + !Array.isArray(raw["calls"]) || + typeof raw["text"] !== "string" || + (raw["stopReason"] !== "end_turn" && + raw["stopReason"] !== "max_steps" && + raw["stopReason"] !== "error") + ) + throw new Error( + "A replay run needs calls, text, and stopReason (end_turn, max_steps, or error)", + ); + const calls = raw["calls"].map((call): ReplayCall => { + if ( + !isObject(call) || + typeof call["tool"] !== "string" || + !isObject(call["input"]) || + (call["isError"] !== undefined && typeof call["isError"] !== "boolean") + ) + throw new Error("A replay call needs tool, object input, and optional boolean isError"); + return { + tool: call["tool"], + input: call["input"], + ...(typeof call["isError"] === "boolean" ? { isError: call["isError"] } : {}), + }; + }); + return { calls, text: raw["text"], stopReason: raw["stopReason"] }; +}; diff --git a/scripts/knip-schemas.test.ts b/scripts/knip-schemas.test.ts index bc7a00e249..f8474452e8 100644 --- a/scripts/knip-schemas.test.ts +++ b/scripts/knip-schemas.test.ts @@ -10,6 +10,51 @@ const require = NodeModule.createRequire(import.meta.url); const cli = NodePath.join(NodePath.dirname(require.resolve("knip")), "cli.js"); const preprocessor = NodePath.join(import.meta.dirname, "knip-schemas.ts"); +it("keeps the standalone replay CLI reachable without hiding unused scripts", () => { + const cwd = NodeFS.mkdtempSync(NodePath.join(import.meta.dirname, ".knip-test-")); + try { + NodeFS.writeFileSync( + NodePath.join(cwd, "package.json"), + JSON.stringify({ private: true, workspaces: ["scripts"] }), + ); + NodeFS.mkdirSync(NodePath.join(cwd, "scripts/agent-surface/eval"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(cwd, "scripts/package.json"), + JSON.stringify({ name: "replay-entry-fixture", private: true, type: "module" }), + ); + for (const file of ["replay-run.ts", "unused.ts"]) + NodeFS.writeFileSync( + NodePath.join(cwd, "scripts/agent-surface/eval", file), + "console.log(1);", + ); + const result = NodeChildProcess.spawnSync( + NodeProcess.execPath, + [ + cli, + "--directory", + cwd, + "--config", + NodePath.join(import.meta.dirname, "../knip.jsonc"), + "--workspace", + "scripts", + "--include", + "files", + "--no-config-hints", + "--reporter", + "json", + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr + result.stdout).toBe(1); + const issues = JSON.parse(result.stdout).issues; + expect(issues.map((issue: { file: string }) => issue.file)).toEqual([ + "scripts/agent-surface/eval/unused.ts", + ]); + } finally { + NodeFS.rmSync(cwd, { recursive: true, force: true }); + } +}); + it("allows types and schemas through the real Knip CLI without hiding runtime or file findings", () => { // Keeping the disposable project here gives it the same Effect installation as the scripts. const cwd = NodeFS.mkdtempSync(NodePath.join(import.meta.dirname, ".knip-test-"));