fix(hooks): propagate run/tool IDs for tool hook correlation (#32360)

* Plugin SDK: add run and tool call fields to tool hooks

* Agents: propagate runId and toolCallId in before_tool_call

* Agents: thread runId through tool wrapper context

* Runner: pass runId into tool hook context

* Compaction: pass runId into tool hook context

* Agents: scope after_tool_call start data by run

* Tests: cover run and tool IDs in before_tool_call hooks

* Tests: add run-scoped after_tool_call collision coverage

* Hooks: scope adjusted tool params by run

* Tests: cover run-scoped adjusted param collisions

* Hooks: preserve active tool start metadata until end

* Changelog: add tool-hook correlation note
This commit is contained in:
Vincent Koc
2026-03-02 17:23:08 -08:00
committed by GitHub
parent 61adcea68e
commit 747902a26a
9 changed files with 200 additions and 19 deletions

View File

@@ -3,7 +3,11 @@ import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-sessio
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
import {
__testing as beforeToolCallTesting,
consumeAdjustedParamsForToolCall,
wrapToolWithBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
vi.mock("../plugins/hook-runner-global.js");
@@ -37,6 +41,7 @@ describe("before_tool_call hook integration", () => {
beforeEach(() => {
resetDiagnosticSessionStateForTest();
beforeToolCallTesting.adjustedParamsByToolCallId.clear();
hookRunner = installMockHookRunner();
});
@@ -123,6 +128,7 @@ describe("before_tool_call hook integration", () => {
agentId: "main",
sessionKey: "main",
sessionId: "ephemeral-main",
runId: "run-main",
});
const extensionContext = {} as Parameters<typeof tool.execute>[3];
@@ -132,15 +138,51 @@ describe("before_tool_call hook integration", () => {
{
toolName: "read",
params: {},
runId: "run-main",
toolCallId: "call-5",
},
{
toolName: "read",
agentId: "main",
sessionKey: "main",
sessionId: "ephemeral-main",
runId: "run-main",
toolCallId: "call-5",
},
);
});
it("keeps adjusted params isolated per run when toolCallId collides", async () => {
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runBeforeToolCall
.mockResolvedValueOnce({ params: { marker: "A" } })
.mockResolvedValueOnce({ params: { marker: "B" } });
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any
const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
runId: "run-a",
});
// oxlint-disable-next-line typescript/no-explicit-any
const toolB = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
runId: "run-b",
});
const extensionContextA = {} as Parameters<typeof toolA.execute>[3];
const extensionContextB = {} as Parameters<typeof toolB.execute>[3];
const sharedToolCallId = "shared-call";
await toolA.execute(sharedToolCallId, { path: "/tmp/a.txt" }, undefined, extensionContextA);
await toolB.execute(sharedToolCallId, { path: "/tmp/b.txt" }, undefined, extensionContextB);
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-a")).toEqual({
path: "/tmp/a.txt",
marker: "A",
});
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-b")).toEqual({
path: "/tmp/b.txt",
marker: "B",
});
expect(consumeAdjustedParamsForToolCall(sharedToolCallId, "run-a")).toBeUndefined();
});
});
describe("before_tool_call hook deduplication (#15502)", () => {