fix(claude): skip user and project hooks for internal agents (#5750)

Branch-name, commit message, and pull request text generation run as
internal Claude agents in the workspace, so a project Stop hook that
pushes HEAD published the placeholder branch before the rename.

Closes #5742

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-30 12:10:14 +02:00
committed by GitHub
parent 5599f9e567
commit b1a3b0772d
3 changed files with 70 additions and 2 deletions
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import { createServer } from "http";
import { mkdtempSync, rmSync } from "fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { randomUUID } from "crypto";
@@ -250,4 +250,39 @@ describe("getStructuredAgentResponse (e2e)", () => {
expect(result.message.trim().toLowerCase()).toBe("hello");
}, 180000);
test("does not run the project's Claude hooks for an internal Claude agent", async (context) => {
if (!canRunClaude) {
context.skip();
}
const hookMarker = path.join(cwd, "stop-hook-ran");
mkdirSync(path.join(cwd, ".claude"));
writeFileSync(
path.join(cwd, ".claude", "settings.json"),
JSON.stringify({
hooks: {
Stop: [{ hooks: [{ type: "command", command: `touch '${hookMarker}'` }] }],
},
}),
);
const result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "claude",
model: CLAUDE_TEST_MODEL,
cwd,
title: "Branch name generator",
internal: true,
},
prompt:
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
schema: z.object({ message: z.string() }),
maxRetries: 2,
persistSession: false,
});
expect(result.message.trim().toLowerCase()).toBe("hello");
expect(existsSync(hookMarker)).toBe(false);
}, 180000);
});
@@ -1067,6 +1067,35 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("disables Claude hooks for internal agents only", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const internalSession = await client.createSession({
provider: "claude",
cwd: process.cwd(),
internal: true,
});
const userSession = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
await internalSession.startTurn("hello");
await userSession.startTurn("hello");
expect(queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
disableAllHooks: true,
});
expect(queryFactory.mock.calls[1]?.[0].options.settings).toBeUndefined();
await internalSession.close();
await userSession.close();
});
test("turns Claude thinking off without retaining an effort level", async () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -3363,13 +3363,17 @@ class ClaudeAgentSession implements AgentSession {
input: { ultracode: boolean },
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
const fastMode = this.resolveFastModeSetting();
if (fastMode === null && !input.ultracode) {
// Internal agents do daemon work such as naming a branch, so the user's and
// project's hooks must not run for them.
const disableAllHooks = this.config.internal === true;
if (fastMode === null && !input.ultracode && !disableAllHooks) {
return {};
}
return {
settings: mergeClaudeSettings(providerOptions.settings, {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
}),
};
}