From 1614f1fe7a211567f249a0ff28f93f096e146389 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:56:55 -0700 Subject: [PATCH 1/3] fix(miner): derive sdk changed files from git --- .../src/miner/agent-sdk-driver.ts | 50 ++++++-- .../test/agent-sdk-driver.test.ts | 72 ++++++++++-- test/unit/agent-sdk-driver.test.ts | 111 ++++++++++++++++-- 3 files changed, 209 insertions(+), 24 deletions(-) diff --git a/packages/gittensory-engine/src/miner/agent-sdk-driver.ts b/packages/gittensory-engine/src/miner/agent-sdk-driver.ts index 5d21ff518..3eeaa8be4 100644 --- a/packages/gittensory-engine/src/miner/agent-sdk-driver.ts +++ b/packages/gittensory-engine/src/miner/agent-sdk-driver.ts @@ -8,6 +8,9 @@ // matcher, #2343's stated attachment point) and this driver forwards them verbatim onto the `query()` options, so // house-rule enforcement can intercept every tool call before execution without this module knowing the rules. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + import { redactSecrets } from "../subprocess-env.js"; import type { CodingAgentDriver, @@ -40,8 +43,7 @@ export type AgentSdkQueryFn = (input: { options: AgentSdkQueryOptions; }) => AsyncIterable>; -/** Tool names whose successful use means a file in the working directory changed. */ -const FILE_EDIT_TOOL_NAMES = new Set(["Edit", "Write", "NotebookEdit"]); +const execFileAsync = promisify(execFile); /** Ceiling for any redacted free text surfaced on the result (error detail, summary) — one named place. */ const MAX_REDACTED_TEXT_LENGTH = 500; @@ -66,12 +68,30 @@ export type CreateAgentSdkDriverOptions = { query?: AgentSdkQueryFn | undefined; /** Forwarded verbatim to the SDK session — the #2343 `PreToolUse` interception point. */ hooks?: AgentSdkHooks | undefined; + /** Injected changed-file enumerator; defaults to git diff over the worktree. */ + listChangedFiles?: ((cwd: string) => Promise) | undefined; }; function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null ? (value as Record) : null; } +async function listWorktreeChangedFiles(cwd: string): Promise { + const [tracked, untracked] = await Promise.all([ + execFileAsync("git", ["-C", cwd, "diff", "--name-only", "HEAD", "--"]), + execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"]), + ]); + return Array.from( + new Set( + [tracked.stdout, untracked.stdout] + .join("\n") + .split(/\r?\n/) + .map((file) => file.trim()) + .filter(Boolean), + ), + ); +} + /** Fold one assistant message's content blocks into the transcript/changed-file accumulators. */ function foldAssistantMessage( message: Record, @@ -87,9 +107,7 @@ function foldAssistantMessage( transcript.push(block.text); } else if (block.type === "tool_use" && typeof block.name === "string") { const filePath = asRecord(block.input)?.file_path; - if (FILE_EDIT_TOOL_NAMES.has(block.name) && typeof filePath === "string") { - changedFiles.add(filePath); - } + if (typeof filePath === "string") changedFiles.add(filePath); } } } @@ -105,6 +123,7 @@ export function createAgentSdkCodingAgentDriver( options: CreateAgentSdkDriverOptions = {}, ): CodingAgentDriver { const query = options.query ?? defaultQuery; + const listChangedFiles = options.listChangedFiles ?? listWorktreeChangedFiles; return { async run(task: CodingAgentDriverTask): Promise { @@ -179,10 +198,27 @@ export function createAgentSdkCodingAgentDriver( }; } + let worktreeChangedFiles: string[]; + try { + worktreeChangedFiles = await listChangedFiles(task.workingDirectory); + } catch (error) { + const detail = redactSecrets(error instanceof Error ? error.message : String(error)).slice(0, MAX_REDACTED_TEXT_LENGTH); + return { + ok: false, + changedFiles: [], + summary: "agent sdk changed-file enumeration failed", + transcript, + turnsUsed, + costUsd, + error: `agent_sdk_changed_files_unavailable: ${detail}`, + }; + } + + const allChangedFiles = Array.from(new Set([...changedFiles, ...worktreeChangedFiles])); return { ok: true, - changedFiles: [...changedFiles], - summary: resultText.slice(0, MAX_REDACTED_TEXT_LENGTH) || `coding agent completed with ${changedFiles.size} changed file(s)`, + changedFiles: allChangedFiles, + summary: resultText.slice(0, MAX_REDACTED_TEXT_LENGTH) || `coding agent completed with ${allChangedFiles.length} changed file(s)`, transcript, turnsUsed, costUsd, diff --git a/packages/gittensory-engine/test/agent-sdk-driver.test.ts b/packages/gittensory-engine/test/agent-sdk-driver.test.ts index 803fb023a..e62d65ed1 100644 --- a/packages/gittensory-engine/test/agent-sdk-driver.test.ts +++ b/packages/gittensory-engine/test/agent-sdk-driver.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { createAgentSdkCodingAgentDriver, type AgentSdkQueryFn, + type CreateAgentSdkDriverOptions, type CodingAgentDriverTask, } from "../dist/index.js"; @@ -35,10 +36,14 @@ function queryYielding( }; } +function driverWith(options: CreateAgentSdkDriverOptions) { + return createAgentSdkCodingAgentDriver({ listChangedFiles: async () => [], ...options }); +} + test("success: session options, tool-use changed-file tracking, transcript, turn count", async () => { const captured: { input?: Parameters[0] } = {}; const hooks = { PreToolUse: [{ hooks: ["policy-callback"] }] }; - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding( [ assistantMessage({ type: "text", text: "editing now" }), @@ -74,8 +79,59 @@ test("success: session options, tool-use changed-file tracking, transcript, turn assert.equal(captured.input!.options.hooks, hooks); }); +test("success derives changed files from the worktree after untracked mutating tools", async () => { + const driver = driverWith({ + query: queryYielding([ + assistantMessage({ type: "tool_use", name: "Bash", input: { command: "node mutate.js" } }), + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "mutated" }, + ]), + listChangedFiles: async (cwd) => { + assert.equal(cwd, task.workingDirectory); + return ["packages/gittensory-engine/src/vulnerable.ts"]; + }, + }); + + const result = await driver.run(task); + + assert.equal(result.ok, true); + assert.deepEqual(result.changedFiles, ["packages/gittensory-engine/src/vulnerable.ts"]); +}); + +test("success fails closed when changed-file enumeration is unavailable", async () => { + const driver = driverWith({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + ]), + listChangedFiles: async () => { + throw new Error("not a git worktree"); + }, + }); + + const result = await driver.run(task); + + assert.equal(result.ok, false); + assert.deepEqual(result.changedFiles, []); + assert.match(result.error!, /agent_sdk_changed_files_unavailable: not a git worktree/); +}); + +test("success stringifies a non-Error changed-file enumeration failure", async () => { + const driver = driverWith({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + ]), + listChangedFiles: async () => { + throw "git unavailable"; + }, + }); + + const result = await driver.run(task); + + assert.equal(result.ok, false); + assert.equal(result.error, "agent_sdk_changed_files_unavailable: git unavailable"); +}); + test("non-success result subtype maps to a structured failure with the subtype as the error", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ assistantMessage({ type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }), { type: "result", subtype: "error_max_turns", is_error: true, num_turns: 6 }, @@ -90,7 +146,7 @@ test("non-success result subtype maps to a structured failure with the subtype a }); test("a success-subtype result that still flags is_error is treated as a failure", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "result", subtype: "success", is_error: true, num_turns: 1, result: "refused" }, ]), @@ -101,7 +157,7 @@ test("a success-subtype result that still flags is_error is treated as a failure }); test("stream ending without a result frame is a protocol failure, not a silent success", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([assistantMessage({ type: "text", text: "started..." })]), }); const result = await driver.run(task); @@ -111,7 +167,7 @@ test("stream ending without a result frame is a protocol failure, not a silent s }); test("a throw mid-stream returns a redacted structured failure and never propagates", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: () => (async function* (): AsyncGenerator> { yield assistantMessage({ type: "text", text: "before the crash" }); @@ -127,7 +183,7 @@ test("a throw mid-stream returns a redacted structured failure and never propaga }); test("secret shapes in the result text are redacted from summary and transcript", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "result", @@ -146,7 +202,7 @@ test("secret shapes in the result text are redacted from summary and transcript" }); test("malformed frames (no content array, non-object blocks, missing file_path) are skipped defensively", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "assistant" }, { type: "assistant", message: { content: "not-an-array" } }, @@ -167,7 +223,7 @@ test("malformed frames (no content array, non-object blocks, missing file_path) }); test("names a result frame with no usable subtype 'unknown'", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([{ type: "result", is_error: true }]), }); const result = await driver.run(task); diff --git a/test/unit/agent-sdk-driver.test.ts b/test/unit/agent-sdk-driver.test.ts index 0accbe4c8..037c82a72 100644 --- a/test/unit/agent-sdk-driver.test.ts +++ b/test/unit/agent-sdk-driver.test.ts @@ -1,12 +1,21 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + import { describe, expect, it } from "vitest"; import { createAgentSdkCodingAgentDriver, type AgentSdkQueryFn, + type CreateAgentSdkDriverOptions, type CodingAgentDriverTask, } from "../../packages/gittensory-engine/src/index"; // Secret-shaped strings are BUILT AT RUNTIME so the diff never contains a token-shaped literal (the // repo secret scanner pattern-matches raw diff text; redactSecrets only needs the shape to exist at runtime). +const execFileAsync = promisify(execFile); + const fakeApiKey = ["sk", "abcdefghijklmnop1234"].join("-"); const fakeGithubToken = ["ghp", "abcdefghijklmnopqrst123456"].join("_"); @@ -34,11 +43,15 @@ function queryYielding( }; } +function driverWith(options: CreateAgentSdkDriverOptions) { + return createAgentSdkCodingAgentDriver({ listChangedFiles: async () => [], ...options }); +} + describe("createAgentSdkCodingAgentDriver", () => { it("maps a successful session: options, hook pass-through, changed-file tracking, turn count", async () => { const captured: { input?: Parameters[0] } = {}; const hooks = { PreToolUse: [{ hooks: ["policy-callback"] }] }; - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding( [ assistantMessage({ type: "text", text: "editing now" }), @@ -74,8 +87,88 @@ describe("createAgentSdkCodingAgentDriver", () => { expect(captured.input!.options.hooks).toBe(hooks); }); + it("enumerates tracked and untracked worktree changes with git", async () => { + const dir = await mkdtemp(join(tmpdir(), "gittensory-agent-sdk-")); + try { + await execFileAsync("git", ["init"], { cwd: dir }); + await execFileAsync("git", ["config", "user.email", "test@example.invalid"], { cwd: dir }); + await execFileAsync("git", ["config", "user.name", "Test User"], { cwd: dir }); + await writeFile(join(dir, "tracked.ts"), "export const value = 1;\n"); + await execFileAsync("git", ["add", "tracked.ts"], { cwd: dir }); + await execFileAsync("git", ["commit", "-m", "init"], { cwd: dir }); + + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + assistantMessage({ type: "tool_use", name: "Bash", input: { command: "node mutate.js" } }), + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "mutated" }, + ]), + }); + + await writeFile(join(dir, "tracked.ts"), "export const value = 2;\n"); + await writeFile(join(dir, "untracked.ts"), "export const fresh = true;\n"); + + const result = await driver.run({ ...task, workingDirectory: dir }); + + expect(result.ok).toBe(true); + expect(result.changedFiles).toEqual(["tracked.ts", "untracked.ts"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("derives changed files from the worktree after untracked mutating tools", async () => { + const driver = driverWith({ + query: queryYielding([ + assistantMessage({ type: "tool_use", name: "Bash", input: { command: "node mutate.js" } }), + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "mutated" }, + ]), + listChangedFiles: async (cwd) => { + expect(cwd).toBe(task.workingDirectory); + return ["packages/gittensory-engine/src/vulnerable.ts"]; + }, + }); + + const result = await driver.run(task); + + expect(result.ok).toBe(true); + expect(result.changedFiles).toEqual(["packages/gittensory-engine/src/vulnerable.ts"]); + }); + + it("fails closed when changed-file enumeration is unavailable", async () => { + const driver = driverWith({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + ]), + listChangedFiles: async () => { + throw new Error("not a git worktree"); + }, + }); + + const result = await driver.run(task); + + expect(result.ok).toBe(false); + expect(result.changedFiles).toEqual([]); + expect(result.error).toContain("agent_sdk_changed_files_unavailable: not a git worktree"); + }); + + it("stringifies a non-Error changed-file enumeration failure", async () => { + const driver = driverWith({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + ]), + listChangedFiles: async () => { + throw "git unavailable"; + }, + }); + + const result = await driver.run(task); + + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_changed_files_unavailable: git unavailable"); + }); + it("maps a non-success result subtype to a structured failure named by the subtype", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ assistantMessage({ type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }), { type: "result", subtype: "error_max_turns", is_error: true, num_turns: 6 }, @@ -90,7 +183,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("treats a success-subtype result that still flags is_error as a failure", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "result", subtype: "success", is_error: true, num_turns: 1, result: "refused" }, ]), @@ -101,7 +194,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("names a result frame with no usable subtype 'unknown'", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([{ type: "result", is_error: true }]), }); const result = await driver.run(task); @@ -111,7 +204,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("treats a stream that ends without a result frame as a protocol failure", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([assistantMessage({ type: "text", text: "started..." })]), }); const result = await driver.run(task); @@ -121,7 +214,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("returns a redacted structured failure when the stream throws an Error", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: () => (async function* (): AsyncGenerator> { yield assistantMessage({ type: "text", text: "before the crash" }); @@ -137,7 +230,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("stringifies a non-Error throw instead of crashing", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: () => (async function* (): AsyncGenerator> { throw "bridge exited 137"; @@ -149,7 +242,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("redacts secret shapes from the summary and transcript", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "result", @@ -168,7 +261,7 @@ describe("createAgentSdkCodingAgentDriver", () => { }); it("skips malformed frames defensively and falls back to the count summary on empty result text", async () => { - const driver = createAgentSdkCodingAgentDriver({ + const driver = driverWith({ query: queryYielding([ { type: "assistant" }, { type: "assistant", message: { content: "not-an-array" } }, From bcce71c96288a2a919600d6e3095db40be64039f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:38:58 -0700 Subject: [PATCH 2/3] fix(miner): surface costUsd on the changed-files-enumeration-failure path too Reconciling this branch with #5356 (costUsd wiring) surfaced a gap: the new changed-file-enumeration-failure return statement ran after the SDK result message (and its billed total_cost_usd) was already available, but omitted costUsd -- silently undercounting real spend for that one failure path in budgetSpent. Added it, plus regression coverage in both the node:test source and its vitest/Codecov mirror. --- packages/gittensory-engine/test/agent-sdk-driver.test.ts | 7 +++++-- test/unit/agent-sdk-driver.test.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/gittensory-engine/test/agent-sdk-driver.test.ts b/packages/gittensory-engine/test/agent-sdk-driver.test.ts index e62d65ed1..a2e42ea51 100644 --- a/packages/gittensory-engine/test/agent-sdk-driver.test.ts +++ b/packages/gittensory-engine/test/agent-sdk-driver.test.ts @@ -97,10 +97,10 @@ test("success derives changed files from the worktree after untracked mutating t assert.deepEqual(result.changedFiles, ["packages/gittensory-engine/src/vulnerable.ts"]); }); -test("success fails closed when changed-file enumeration is unavailable", async () => { +test("success fails closed when changed-file enumeration is unavailable, but still reports the real dollar cost", async () => { const driver = driverWith({ query: queryYielding([ - { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done", total_cost_usd: 0.0042 }, ]), listChangedFiles: async () => { throw new Error("not a git worktree"); @@ -112,6 +112,9 @@ test("success fails closed when changed-file enumeration is unavailable", async assert.equal(result.ok, false); assert.deepEqual(result.changedFiles, []); assert.match(result.error!, /agent_sdk_changed_files_unavailable: not a git worktree/); + // The SDK session ran and was billed before enumeration ever failed -- budgetSpent must not silently + // undercount this path just because the changed-files step failed afterward. + assert.equal(result.costUsd, 0.0042); }); test("success stringifies a non-Error changed-file enumeration failure", async () => { diff --git a/test/unit/agent-sdk-driver.test.ts b/test/unit/agent-sdk-driver.test.ts index 037c82a72..26fdd8a6f 100644 --- a/test/unit/agent-sdk-driver.test.ts +++ b/test/unit/agent-sdk-driver.test.ts @@ -134,10 +134,10 @@ describe("createAgentSdkCodingAgentDriver", () => { expect(result.changedFiles).toEqual(["packages/gittensory-engine/src/vulnerable.ts"]); }); - it("fails closed when changed-file enumeration is unavailable", async () => { + it("fails closed when changed-file enumeration is unavailable, but still reports the real dollar cost", async () => { const driver = driverWith({ query: queryYielding([ - { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }, + { type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done", total_cost_usd: 0.0042 }, ]), listChangedFiles: async () => { throw new Error("not a git worktree"); @@ -149,6 +149,9 @@ describe("createAgentSdkCodingAgentDriver", () => { expect(result.ok).toBe(false); expect(result.changedFiles).toEqual([]); expect(result.error).toContain("agent_sdk_changed_files_unavailable: not a git worktree"); + // The SDK session ran and was billed before enumeration ever failed -- budgetSpent must not silently + // undercount this path just because the changed-files step failed afterward. + expect(result.costUsd).toBe(0.0042); }); it("stringifies a non-Error changed-file enumeration failure", async () => { From 2ff997e90255501c08988d5ca5fa0ea1e9aaa3ee Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:07:37 -0700 Subject: [PATCH 3/3] fix(miner): thread listChangedFiles through the driver factory as a real test seam Reconciling this branch's default agent-sdk changed-file enumeration (now a real git diff/ls-files read) surfaced that createCodingAgentDriver, constructProductionCodingAgentDriver, and runCodingAgentAttempt never forwarded an injectable enumerator -- every pre-existing test that constructs a real agent-sdk driver against a fake (non-git) working directory now fails closed via the real default, since there was no seam to opt out with. Threaded listChangedFiles through all three layers (same pattern as query/hooks) and updated the 4 affected test files (contract parity, coding-agent-miner, miner-coding-agent-construction, miner-coding-agent-house-rules) to inject a no-op enumerator where they test other behavior. --- packages/gittensory-engine/src/miner/driver-factory.ts | 8 ++++++++ .../gittensory-miner/lib/coding-agent-construction.d.ts | 1 + .../gittensory-miner/lib/coding-agent-construction.js | 2 ++ test/contract/coding-agent-driver-parity.test.ts | 3 +++ test/unit/coding-agent-miner.test.ts | 3 +++ test/unit/miner-coding-agent-construction.test.ts | 5 ++++- test/unit/miner-coding-agent-house-rules.test.ts | 5 +++++ 7 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/gittensory-engine/src/miner/driver-factory.ts b/packages/gittensory-engine/src/miner/driver-factory.ts index e0014c67b..53a39f059 100644 --- a/packages/gittensory-engine/src/miner/driver-factory.ts +++ b/packages/gittensory-engine/src/miner/driver-factory.ts @@ -116,6 +116,10 @@ export type CreateCodingAgentDriverOptions = { query?: AgentSdkQueryFn | undefined; /** Forwarded to the `agent-sdk` provider's session (#2343's PreToolUse interception point). */ hooks?: AgentSdkHooks | undefined; + /** Optional injected changed-file enumerator for the `agent-sdk` provider (defaults to a real `git diff`/ + * `git ls-files` read over the task's working directory — a test harness pointing at a non-git fake path + * should inject one, same seam as `query`). */ + listChangedFiles?: ((cwd: string) => Promise) | undefined; /** Known secret values the CLI providers strip from surfaced output, on top of the token-shape patterns. */ knownSecrets?: readonly string[] | undefined; }; @@ -190,6 +194,7 @@ export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions) return createAgentSdkCodingAgentDriver({ ...(options.query !== undefined ? { query: options.query } : {}), ...(options.hooks !== undefined ? { hooks: options.hooks } : {}), + ...(options.listChangedFiles !== undefined ? { listChangedFiles: options.listChangedFiles } : {}), }); /* v8 ignore next 2 -- isConfiguredCodingAgentDriver already rejects unknown names before this switch. */ default: @@ -209,6 +214,8 @@ export type RunCodingAgentAttemptOptions = { spawn?: CliSubprocessSpawnFn | undefined; query?: AgentSdkQueryFn | undefined; hooks?: AgentSdkHooks | undefined; + /** Optional injected changed-file enumerator for the `agent-sdk` provider (see `CreateCodingAgentDriverOptions`). */ + listChangedFiles?: ((cwd: string) => Promise) | undefined; knownSecrets?: readonly string[] | undefined; /** When supplied, the driver result is run through the lint guard (#4276) before being returned, so a * live coding-agent edit that fails its own package's typecheck/node --check never reads as `ok: true`. */ @@ -227,6 +234,7 @@ function resolveDriverForAttempt(options: RunCodingAgentAttemptOptions, mode: Co spawn: options.spawn, query: options.query, hooks: options.hooks, + listChangedFiles: options.listChangedFiles, knownSecrets: options.knownSecrets, }); } diff --git a/packages/gittensory-miner/lib/coding-agent-construction.d.ts b/packages/gittensory-miner/lib/coding-agent-construction.d.ts index eefafe589..fe3eda2c9 100644 --- a/packages/gittensory-miner/lib/coding-agent-construction.d.ts +++ b/packages/gittensory-miner/lib/coding-agent-construction.d.ts @@ -6,6 +6,7 @@ export type ConstructProductionCodingAgentDriverOptions = { spawn?: CliSubprocessSpawnFn; query?: AgentSdkQueryFn; hooks?: AgentSdkHooks; + listChangedFiles?: (cwd: string) => Promise; houseRulesConfig?: unknown; houseRulesOptions?: unknown; }; diff --git a/packages/gittensory-miner/lib/coding-agent-construction.js b/packages/gittensory-miner/lib/coding-agent-construction.js index d463649b1..79308aa51 100644 --- a/packages/gittensory-miner/lib/coding-agent-construction.js +++ b/packages/gittensory-miner/lib/coding-agent-construction.js @@ -76,6 +76,7 @@ export function createRealCliSubprocessSpawn() { * spawn?: import("@jsonbored/gittensory-engine").CliSubprocessSpawnFn, * query?: import("@jsonbored/gittensory-engine").AgentSdkQueryFn, * hooks?: import("@jsonbored/gittensory-engine").AgentSdkHooks, + * listChangedFiles?: (cwd: string) => Promise, * houseRulesConfig?: unknown, * houseRulesOptions?: unknown, * }} [options] @@ -97,5 +98,6 @@ export function constructProductionCodingAgentDriver(env, options = {}) { spawn: options.spawn ?? createRealCliSubprocessSpawn(), ...(options.query !== undefined ? { query: options.query } : {}), ...(hooks !== undefined ? { hooks } : {}), + ...(options.listChangedFiles !== undefined ? { listChangedFiles: options.listChangedFiles } : {}), }); } diff --git a/test/contract/coding-agent-driver-parity.test.ts b/test/contract/coding-agent-driver-parity.test.ts index d365d9aab..3d77e8dfa 100644 --- a/test/contract/coding-agent-driver-parity.test.ts +++ b/test/contract/coding-agent-driver-parity.test.ts @@ -99,6 +99,9 @@ const DRIVER_HARNESSES: DriverHarness[] = [ make: (behavior) => { const recorded: RecordedInvocation = { cwds: [], prompts: [] }; const driver = createAgentSdkCodingAgentDriver({ + // This suite exercises the shared result-shape/scoping contract, not real git enumeration; the shared + // WORKTREE constant is not an actual git repo, so the real default enumerator would fail closed. + listChangedFiles: async () => [], query: (input) => { recorded.cwds.push(input.options.cwd); recorded.prompts.push(input.prompt); diff --git a/test/unit/coding-agent-miner.test.ts b/test/unit/coding-agent-miner.test.ts index fa99f11bd..63fc76097 100644 --- a/test/unit/coding-agent-miner.test.ts +++ b/test/unit/coding-agent-miner.test.ts @@ -515,6 +515,9 @@ describe("createCodingAgentDriver provider resolution (#4289)", () => { const driver = createCodingAgentDriver({ providerName: "agent-sdk", hooks, + // This test exercises hook forwarding, not real git enumeration; cliTask's working directory is a fake + // path, so the real default enumerator would fail closed. + listChangedFiles: async () => [], query: (input) => { captured = input; return (async function* (): AsyncGenerator> { diff --git a/test/unit/miner-coding-agent-construction.test.ts b/test/unit/miner-coding-agent-construction.test.ts index c001818f4..15ba7c0bb 100644 --- a/test/unit/miner-coding-agent-construction.test.ts +++ b/test/unit/miner-coding-agent-construction.test.ts @@ -141,7 +141,9 @@ describe("constructProductionCodingAgentDriver (#5131)", () => { const captured: { input?: Parameters[0] } = {}; const driver = constructProductionCodingAgentDriver( { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }, - { query: queryCapturing(captured) }, + // This test exercises hook wiring, not real git enumeration; task.workingDirectory is a fake path, so the + // real default enumerator would fail closed. + { query: queryCapturing(captured), listChangedFiles: async () => [] }, ); const result = await driver.run(task); expect(result.ok).toBe(true); @@ -161,6 +163,7 @@ describe("constructProductionCodingAgentDriver (#5131)", () => { { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }, { query: queryCapturing(captured), + listChangedFiles: async () => [], houseRulesConfig: { repoFullName: "acme/widgets" }, houseRulesOptions: { append }, }, diff --git a/test/unit/miner-coding-agent-house-rules.test.ts b/test/unit/miner-coding-agent-house-rules.test.ts index a92f2499d..7e06b98c6 100644 --- a/test/unit/miner-coding-agent-house-rules.test.ts +++ b/test/unit/miner-coding-agent-house-rules.test.ts @@ -75,6 +75,9 @@ describe("runHouseRulesEnforcedCodingAgentAttempt (#2343 follow-up)", () => { providerName: "agent-sdk", task, query: queryCapturing(captured), + // This test exercises hook defaulting, not real git enumeration; `task`'s working directory is a fake + // path, so the real default enumerator would fail closed. + listChangedFiles: async () => [], }); expect(result.mode).toBe("live"); @@ -95,6 +98,7 @@ describe("runHouseRulesEnforcedCodingAgentAttempt (#2343 follow-up)", () => { task, query: queryCapturing(captured), hooks: callerHooks, + listChangedFiles: async () => [], }); expect(captured.input!.options.hooks).toBe(callerHooks); @@ -107,6 +111,7 @@ describe("runHouseRulesEnforcedCodingAgentAttempt (#2343 follow-up)", () => { providerName: "agent-sdk", task, query: queryCapturing(captured), + listChangedFiles: async () => [], houseRulesConfig: { repoFullName: "acme/widgets" }, houseRulesOptions: { append }, });