diff --git a/package-lock.json b/package-lock.json index a64edb5bad..367128b30e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -364,6 +364,156 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz", + "integrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz", + "integrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz", + "integrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz", + "integrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.205.tgz", + "integrity": "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz", + "integrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.205.tgz", + "integrity": "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.205.tgz", + "integrity": "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.205", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.205.tgz", + "integrity": "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.110.0.tgz", + "integrity": "sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", @@ -4971,11 +5121,18 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -9444,6 +9601,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -10701,6 +10865,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -11609,7 +11787,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -13524,6 +13702,17 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -13891,6 +14080,13 @@ "node": ">=18" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -14215,7 +14411,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vary": { @@ -14871,6 +15067,7 @@ "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index ac52d07dd5..ded8806032 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -65,6 +65,7 @@ "test": "npm run build && rm -rf dist-test && tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.205", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 7e870d96bc..b09728a16f 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -220,6 +220,13 @@ export { type RunCodingAgentAttemptOptions, } from "./miner/driver-factory.js"; export * from "./miner/attempt-metering.js"; +export { + createAgentSdkCodingAgentDriver, + type AgentSdkHooks, + type AgentSdkQueryFn, + type AgentSdkQueryOptions, + type CreateAgentSdkDriverOptions, +} from "./miner/agent-sdk-driver.js"; export * from "./plan-export.js"; export { countPlanStepsByStatus } from "./plan-step-stats.js"; export { countPlanSteps } from "./plan-step-count.js"; diff --git a/packages/gittensory-engine/src/miner/agent-sdk-driver.ts b/packages/gittensory-engine/src/miner/agent-sdk-driver.ts new file mode 100644 index 0000000000..8a29441c9a --- /dev/null +++ b/packages/gittensory-engine/src/miner/agent-sdk-driver.ts @@ -0,0 +1,185 @@ +// Agent-SDK `CodingAgentDriver` (#4267): the second implementation of the #4262 seam, driving the coding agent +// in-process via `@anthropic-ai/claude-agent-sdk`'s `query()` async-iterable loop instead of shelling out to a CLI +// binary (#4266). The streamed SDK message/tool-use events are folded into the shared `CodingAgentDriverResult` +// right here — no SDK-specific event type leaks into the interface, so the iterate-loop orchestrator (#2333) can +// swap this driver for the CLI-subprocess one with no caller-side changes. +// +// The SDK session's hook surface is deliberately NOT encapsulated: callers pass `hooks` (e.g. a `PreToolUse` +// 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 { redactSecrets } from "../subprocess-env.js"; +import type { + CodingAgentDriver, + CodingAgentDriverResult, + CodingAgentDriverTask, +} from "./coding-agent-driver.js"; + +/** + * Opaque hook registration forwarded verbatim to the SDK session (`Options['hooks']` — keyed by hook event name, + * e.g. `PreToolUse`). Typed loosely on purpose: the hook contract belongs to the SDK and to the policy module that + * registers the hooks, not to this driver. + */ +export type AgentSdkHooks = Record; + +/** The exact option subset this driver puts on a `query()` session. */ +export type AgentSdkQueryOptions = { + cwd: string; + maxTurns: number; + permissionMode: "acceptEdits"; + hooks?: AgentSdkHooks | undefined; +}; + +/** + * Injected `query()`-shaped function — mirrors the injected-`SpawnFn` testability convention from #4262/#4266 so + * tests drive the driver with a fake async-iterable and CI never makes a real model call. Messages are consumed + * structurally (plain records), matching how the defensive fold below reads them. + */ +export type AgentSdkQueryFn = (input: { + prompt: string; + 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"]); + +/** Ceiling for any redacted free text surfaced on the result (error detail, summary) — one named place. */ +const MAX_REDACTED_TEXT_LENGTH = 500; + +/* v8 ignore start -- real-SDK path: imports @anthropic-ai/claude-agent-sdk and spawns a live session; tests + inject a fake AgentSdkQueryFn instead (same convention as the CLI driver's injected SpawnFn). */ +const defaultQuery: AgentSdkQueryFn = (input) => { + async function* stream(): AsyncGenerator> { + const sdk = (await import("@anthropic-ai/claude-agent-sdk")) as unknown as { + query: (params: { prompt: string; options?: Record }) => AsyncIterable; + }; + for await (const message of sdk.query({ prompt: input.prompt, options: input.options })) { + yield message as Record; + } + } + return stream(); +}; +/* v8 ignore stop */ + +export type CreateAgentSdkDriverOptions = { + /** Injected `query()` loop; defaults to the real `@anthropic-ai/claude-agent-sdk` export. */ + query?: AgentSdkQueryFn | undefined; + /** Forwarded verbatim to the SDK session — the #2343 `PreToolUse` interception point. */ + hooks?: AgentSdkHooks | undefined; +}; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +/** Fold one assistant message's content blocks into the transcript/changed-file accumulators. */ +function foldAssistantMessage( + message: Record, + transcript: string[], + changedFiles: Set, +): void { + const content = asRecord(message.message)?.content; + if (!Array.isArray(content)) return; + for (const rawBlock of content) { + const block = asRecord(rawBlock); + if (!block) continue; + if (block.type === "text" && typeof block.text === "string") { + 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); + } + } + } +} + +/** + * A `CodingAgentDriver` that runs the attempt through an in-process Agent-SDK `query()` session in the task's + * working directory. Mirrors the CLI-subprocess driver's contract: structured failure results (never a throw), + * `changedFiles` reported only on success (the CLI driver cannot know them on failure, and #4296's parity suite + * holds both implementations to the same shape), and `task.instructions` forwarded verbatim as the prompt — the + * acceptance-criteria document already lives inside the worktree at `task.acceptanceCriteriaPath` (#4271). + */ +export function createAgentSdkCodingAgentDriver( + options: CreateAgentSdkDriverOptions = {}, +): CodingAgentDriver { + const query = options.query ?? defaultQuery; + + return { + async run(task: CodingAgentDriverTask): Promise { + const transcriptParts: string[] = []; + const changedFiles = new Set(); + let resultMessage: Record | null = null; + + try { + const stream = query({ + prompt: task.instructions, + options: { + cwd: task.workingDirectory, + maxTurns: task.maxTurns, + // Same edit-permission scope as the CLI-subprocess driver (#4266): `--permission-mode acceptEdits` + // there, `acceptEdits` here — file edits run unattended inside the scoped worktree, nothing broader. + permissionMode: "acceptEdits", + hooks: options.hooks, + }, + }); + for await (const message of stream) { + if (message.type === "assistant") { + foldAssistantMessage(message, transcriptParts, changedFiles); + } else if (message.type === "result") { + resultMessage = message; + } + } + } 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 session threw", + transcript: redactSecrets(transcriptParts.join("\n")), + error: `agent_sdk_thrown: ${detail}`, + }; + } + + const turnsUsed = + typeof resultMessage?.num_turns === "number" ? resultMessage.num_turns : undefined; + const resultText = + typeof resultMessage?.result === "string" ? redactSecrets(resultMessage.result) : ""; + const transcript = redactSecrets( + [...transcriptParts, ...(resultText ? [resultText] : [])].join("\n"), + ); + + // A stream that ends without a `result` frame is a protocol failure, not a silent success. + if (!resultMessage) { + return { + ok: false, + changedFiles: [], + summary: "agent sdk stream ended without a result message", + transcript, + error: "agent_sdk_no_result", + }; + } + + if (resultMessage.subtype !== "success" || resultMessage.is_error === true) { + const subtype = typeof resultMessage.subtype === "string" ? resultMessage.subtype : "unknown"; + return { + ok: false, + changedFiles: [], + summary: "agent sdk session did not complete successfully", + transcript, + turnsUsed, + error: `agent_sdk_${subtype === "success" ? "errored" : subtype}`, + }; + } + + return { + ok: true, + changedFiles: [...changedFiles], + summary: resultText.slice(0, MAX_REDACTED_TEXT_LENGTH) || `coding agent completed with ${changedFiles.size} changed file(s)`, + transcript, + turnsUsed, + }; + }, + }; +} diff --git a/packages/gittensory-engine/test/agent-sdk-driver.test.ts b/packages/gittensory-engine/test/agent-sdk-driver.test.ts new file mode 100644 index 0000000000..803fb023a6 --- /dev/null +++ b/packages/gittensory-engine/test/agent-sdk-driver.test.ts @@ -0,0 +1,182 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createAgentSdkCodingAgentDriver, + type AgentSdkQueryFn, + type CodingAgentDriverTask, +} from "../dist/index.js"; + +// 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 fakeApiKey = ["sk", "abcdefghijklmnop1234"].join("-"); +const fakeGithubToken = ["ghp", "abcdefghijklmnopqrst123456"].join("_"); + +const task: CodingAgentDriverTask = { + attemptId: "attempt-3", + workingDirectory: "/tmp/worktrees/attempt-3", + acceptanceCriteriaPath: "/tmp/worktrees/attempt-3/ACCEPTANCE-CRITERIA.md", + instructions: "Apply the fix described in ACCEPTANCE-CRITERIA.md.", + maxTurns: 6, +}; + +function assistantMessage(...content: Array>): Record { + return { type: "assistant", message: { content } }; +} + +function queryYielding( + messages: Array>, + captured?: { input?: Parameters[0] }, +): AgentSdkQueryFn { + return (input) => { + if (captured) captured.input = input; + return (async function* () { + yield* messages; + })(); + }; +} + +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({ + query: queryYielding( + [ + assistantMessage({ type: "text", text: "editing now" }), + assistantMessage( + { type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }, + { type: "tool_use", name: "Write", input: { file_path: "docs/b.md" } }, + { type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }, + { type: "tool_use", name: "Bash", input: { command: "npm test" } }, + ), + { type: "result", subtype: "success", is_error: false, num_turns: 4, result: "Fixed the bug." }, + ], + captured, + ), + hooks, + }); + + const result = await driver.run(task); + + assert.equal(result.ok, true); + // File-edit tools are deduped; a Bash tool call is not a changed file. + assert.deepEqual(result.changedFiles, ["src/a.ts", "docs/b.md"]); + assert.equal(result.turnsUsed, 4); + assert.equal(result.summary, "Fixed the bug."); + assert.ok(result.transcript!.includes("editing now")); + assert.ok(result.transcript!.includes("Fixed the bug.")); + + // The prompt is the composed instructions verbatim; the session is scoped to the attempt's worktree with the + // task's turn budget, edit-capable permission mode, and the caller's hooks forwarded untouched (#2343). + assert.equal(captured.input!.prompt, task.instructions); + assert.equal(captured.input!.options.cwd, task.workingDirectory); + assert.equal(captured.input!.options.maxTurns, 6); + assert.equal(captured.input!.options.permissionMode, "acceptEdits"); + assert.equal(captured.input!.options.hooks, hooks); +}); + +test("non-success result subtype maps to a structured failure with the subtype as the error", async () => { + const driver = createAgentSdkCodingAgentDriver({ + 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 }, + ]), + }); + const result = await driver.run(task); + assert.equal(result.ok, false); + assert.equal(result.error, "agent_sdk_error_max_turns"); + assert.equal(result.turnsUsed, 6); + // Parity with the CLI-subprocess driver: no changed-file claims on a failed attempt. + assert.deepEqual(result.changedFiles, []); +}); + +test("a success-subtype result that still flags is_error is treated as a failure", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: true, num_turns: 1, result: "refused" }, + ]), + }); + const result = await driver.run(task); + assert.equal(result.ok, false); + assert.equal(result.error, "agent_sdk_errored"); +}); + +test("stream ending without a result frame is a protocol failure, not a silent success", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([assistantMessage({ type: "text", text: "started..." })]), + }); + const result = await driver.run(task); + assert.equal(result.ok, false); + assert.equal(result.error, "agent_sdk_no_result"); + assert.ok(result.transcript!.includes("started...")); +}); + +test("a throw mid-stream returns a redacted structured failure and never propagates", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: () => + (async function* (): AsyncGenerator> { + yield assistantMessage({ type: "text", text: "before the crash" }); + throw new Error(`bridge died: token ${fakeApiKey} leaked`); + })(), + }); + const result = await driver.run(task); + assert.equal(result.ok, false); + assert.match(result.error!, /^agent_sdk_thrown: bridge died/); + assert.ok(!result.error!.includes(fakeApiKey)); + assert.match(result.error!, /\[redacted\]/); + assert.ok(result.transcript!.includes("before the crash")); +}); + +test("secret shapes in the result text are redacted from summary and transcript", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { + type: "result", + subtype: "success", + is_error: false, + num_turns: 2, + result: `done, but echoed ${fakeGithubToken}`, + }, + ]), + }); + const result = await driver.run(task); + assert.equal(result.ok, true); + assert.ok(!result.summary.includes(fakeGithubToken)); + assert.match(result.summary, /\[redacted\]/); + assert.ok(!result.transcript!.includes(fakeGithubToken)); +}); + +test("malformed frames (no content array, non-object blocks, missing file_path) are skipped defensively", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { type: "assistant" }, + { type: "assistant", message: { content: "not-an-array" } }, + assistantMessage("not-an-object" as unknown as Record, { + type: "tool_use", + name: "Edit", + input: { no_file_path: true }, + }), + { type: "status" }, + { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "" }, + ]), + }); + const result = await driver.run(task); + assert.equal(result.ok, true); + assert.deepEqual(result.changedFiles, []); + // Empty result text falls back to the count summary. + assert.match(result.summary, /0 changed file\(s\)/); +}); + +test("names a result frame with no usable subtype 'unknown'", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([{ type: "result", is_error: true }]), + }); + const result = await driver.run(task); + assert.equal(result.ok, false); + assert.equal(result.error, "agent_sdk_unknown"); + assert.equal(result.turnsUsed, undefined); +}); + +test("constructs with no options, defaulting to the real SDK query loop without invoking it", () => { + const driver = createAgentSdkCodingAgentDriver(); + assert.equal(typeof driver.run, "function"); +}); diff --git a/test/unit/agent-sdk-driver.test.ts b/test/unit/agent-sdk-driver.test.ts new file mode 100644 index 0000000000..0accbe4c8b --- /dev/null +++ b/test/unit/agent-sdk-driver.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + createAgentSdkCodingAgentDriver, + type AgentSdkQueryFn, + 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 fakeApiKey = ["sk", "abcdefghijklmnop1234"].join("-"); +const fakeGithubToken = ["ghp", "abcdefghijklmnopqrst123456"].join("_"); + +const task: CodingAgentDriverTask = { + attemptId: "attempt-3", + workingDirectory: "/tmp/worktrees/attempt-3", + acceptanceCriteriaPath: "/tmp/worktrees/attempt-3/ACCEPTANCE-CRITERIA.md", + instructions: "Apply the fix described in ACCEPTANCE-CRITERIA.md.", + maxTurns: 6, +}; + +function assistantMessage(...content: Array>): Record { + return { type: "assistant", message: { content } }; +} + +function queryYielding( + messages: Array>, + captured?: { input?: Parameters[0] }, +): AgentSdkQueryFn { + return (input) => { + if (captured) captured.input = input; + return (async function* () { + yield* messages; + })(); + }; +} + +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({ + query: queryYielding( + [ + assistantMessage({ type: "text", text: "editing now" }), + assistantMessage( + { type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }, + { type: "tool_use", name: "Write", input: { file_path: "docs/b.md" } }, + { type: "tool_use", name: "Edit", input: { file_path: "src/a.ts" } }, + { type: "tool_use", name: "Bash", input: { command: "npm test" } }, + ), + { type: "result", subtype: "success", is_error: false, num_turns: 4, result: "Fixed the bug." }, + ], + captured, + ), + hooks, + }); + + const result = await driver.run(task); + + expect(result.ok).toBe(true); + // File-edit tools are deduped; a Bash tool call is not a changed file. + expect(result.changedFiles).toEqual(["src/a.ts", "docs/b.md"]); + expect(result.turnsUsed).toBe(4); + expect(result.summary).toBe("Fixed the bug."); + expect(result.transcript).toContain("editing now"); + expect(result.transcript).toContain("Fixed the bug."); + + // The prompt is the composed instructions verbatim; the session is scoped to the attempt's worktree with + // the task's turn budget, edit-capable permission mode, and the caller's hooks forwarded untouched (#2343). + expect(captured.input!.prompt).toBe(task.instructions); + expect(captured.input!.options.cwd).toBe(task.workingDirectory); + expect(captured.input!.options.maxTurns).toBe(6); + expect(captured.input!.options.permissionMode).toBe("acceptEdits"); + expect(captured.input!.options.hooks).toBe(hooks); + }); + + it("maps a non-success result subtype to a structured failure named by the subtype", async () => { + const driver = createAgentSdkCodingAgentDriver({ + 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 }, + ]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_error_max_turns"); + expect(result.turnsUsed).toBe(6); + // Parity with the CLI-subprocess driver: no changed-file claims on a failed attempt. + expect(result.changedFiles).toEqual([]); + }); + + it("treats a success-subtype result that still flags is_error as a failure", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { type: "result", subtype: "success", is_error: true, num_turns: 1, result: "refused" }, + ]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_errored"); + }); + + it("names a result frame with no usable subtype 'unknown'", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([{ type: "result", is_error: true }]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_unknown"); + expect(result.turnsUsed).toBeUndefined(); + }); + + it("treats a stream that ends without a result frame as a protocol failure", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([assistantMessage({ type: "text", text: "started..." })]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_no_result"); + expect(result.transcript).toContain("started..."); + }); + + it("returns a redacted structured failure when the stream throws an Error", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: () => + (async function* (): AsyncGenerator> { + yield assistantMessage({ type: "text", text: "before the crash" }); + throw new Error(`bridge died: token ${fakeApiKey} leaked`); + })(), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/^agent_sdk_thrown: bridge died/); + expect(result.error).not.toContain(fakeApiKey); + expect(result.error).toContain("[redacted]"); + expect(result.transcript).toContain("before the crash"); + }); + + it("stringifies a non-Error throw instead of crashing", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: () => + (async function* (): AsyncGenerator> { + throw "bridge exited 137"; + })(), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("agent_sdk_thrown: bridge exited 137"); + }); + + it("redacts secret shapes from the summary and transcript", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { + type: "result", + subtype: "success", + is_error: false, + num_turns: 2, + result: `done, but echoed ${fakeGithubToken}`, + }, + ]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(true); + expect(result.summary).not.toContain(fakeGithubToken); + expect(result.summary).toContain("[redacted]"); + expect(result.transcript).not.toContain(fakeGithubToken); + }); + + it("skips malformed frames defensively and falls back to the count summary on empty result text", async () => { + const driver = createAgentSdkCodingAgentDriver({ + query: queryYielding([ + { type: "assistant" }, + { type: "assistant", message: { content: "not-an-array" } }, + assistantMessage("not-an-object" as unknown as Record, { + type: "tool_use", + name: "Edit", + input: { no_file_path: true }, + }), + assistantMessage({ type: "tool_use", input: { file_path: "nameless.ts" } }), + { type: "status" }, + { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "" }, + ]), + }); + const result = await driver.run(task); + expect(result.ok).toBe(true); + expect(result.changedFiles).toEqual([]); + // Empty result text falls back to the count summary. + expect(result.summary).toMatch(/0 changed file\(s\)/); + }); + + it("constructs with no options, defaulting to the real SDK query loop without invoking it", () => { + const driver = createAgentSdkCodingAgentDriver(); + expect(typeof driver.run).toBe("function"); + }); +});