Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions packages/gittensory-engine/src/miner/agent-sdk-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,8 +43,7 @@ export type AgentSdkQueryFn = (input: {
options: AgentSdkQueryOptions;
}) => AsyncIterable<Record<string, unknown>>;

/** 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;
Expand All @@ -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<string[]>) | undefined;
};

function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}

async function listWorktreeChangedFiles(cwd: string): Promise<string[]> {
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<string, unknown>,
Expand All @@ -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);
}
}
}
Expand All @@ -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<CodingAgentDriverResult> {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/gittensory-engine/src/miner/driver-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>) | undefined;
/** Known secret values the CLI providers strip from surfaced output, on top of the token-shape patterns. */
knownSecrets?: readonly string[] | undefined;
};
Expand Down Expand Up @@ -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:
Expand All @@ -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<string[]>) | 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`. */
Expand All @@ -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,
});
}
Expand Down
75 changes: 67 additions & 8 deletions packages/gittensory-engine/test/agent-sdk-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
createAgentSdkCodingAgentDriver,
type AgentSdkQueryFn,
type CreateAgentSdkDriverOptions,
type CodingAgentDriverTask,
} from "../dist/index.js";

Expand Down Expand Up @@ -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<AgentSdkQueryFn>[0] } = {};
const hooks = { PreToolUse: [{ hooks: ["policy-callback"] }] };
const driver = createAgentSdkCodingAgentDriver({
const driver = driverWith({
query: queryYielding(
[
assistantMessage({ type: "text", text: "editing now" }),
Expand Down Expand Up @@ -74,8 +79,62 @@ 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, 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", total_cost_usd: 0.0042 },
]),
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/);
// 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 () => {
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 },
Expand All @@ -90,7 +149,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" },
]),
Expand All @@ -101,7 +160,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);
Expand All @@ -111,7 +170,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<Record<string, unknown>> {
yield assistantMessage({ type: "text", text: "before the crash" });
Expand All @@ -127,7 +186,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",
Expand All @@ -146,7 +205,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" } },
Expand All @@ -167,7 +226,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type ConstructProductionCodingAgentDriverOptions = {
spawn?: CliSubprocessSpawnFn;
query?: AgentSdkQueryFn;
hooks?: AgentSdkHooks;
listChangedFiles?: (cwd: string) => Promise<string[]>;
houseRulesConfig?: unknown;
houseRulesOptions?: unknown;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/coding-agent-construction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>,
* houseRulesConfig?: unknown,
* houseRulesOptions?: unknown,
* }} [options]
Expand All @@ -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 } : {}),
});
}
3 changes: 3 additions & 0 deletions test/contract/coding-agent-driver-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading