fix(harness): cover manual lifecycle hooks

This commit is contained in:
Vincent Koc
2026-06-19 13:26:54 +08:00
committed by Vincent Koc
parent 00f67b845b
commit 7c2fb845db
8 changed files with 141 additions and 11 deletions
@@ -1020,6 +1020,9 @@ export async function runCodexAppServerAttempt(
developerInstructions,
messages: codexModelInputHistoryMessages,
ctx: hookContext,
...("beforeAgentStartResult" in params
? { beforeAgentStartResult: params.beforeAgentStartResult }
: {}),
});
const resolveShiftedPromptContextRange = (
prompt: string,
+32 -1
View File
@@ -1,5 +1,10 @@
// Copilot tests cover harness plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
@@ -95,6 +100,10 @@ describe("createCopilotAgentHarness", () => {
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
});
afterEach(() => {
resetGlobalHookRunner();
});
it("returns the copilot id and default label", () => {
const harness = createCopilotAgentHarness();
@@ -1148,6 +1157,7 @@ describe("createCopilotAgentHarness", () => {
copilotHome: "/copilot-home",
auth: { useLoggedInUser: true },
sessionId: "oc-sess-compact",
sessionFile: "/session.json",
...overrides,
};
}
@@ -1179,6 +1189,14 @@ describe("createCopilotAgentHarness", () => {
});
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
const beforeCompaction = vi.fn();
const afterCompaction = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_compaction", handler: beforeCompaction },
{ hookName: "after_compaction", handler: afterCompaction },
]),
);
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
@@ -1241,6 +1259,19 @@ describe("createCopilotAgentHarness", () => {
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(beforeCompaction).toHaveBeenCalledWith(
{ messageCount: -1, sessionFile: "/session.json" },
expect.objectContaining({
modelId: "gpt-4.1",
modelProviderId: "github-copilot",
sessionId: "oc-sess-compact-1",
sessionKey: "agent:main:main",
}),
);
expect(afterCompaction).toHaveBeenCalledWith(
{ compactedCount: 4, messageCount: -1, sessionFile: "/session.json" },
expect.objectContaining({ sessionId: "oc-sess-compact-1" }),
);
expect(result).toEqual({
ok: true,
compacted: true,
+31
View File
@@ -1,8 +1,11 @@
// Copilot plugin module implements harness behavior.
import type { CopilotClient } from "@github/copilot-sdk";
import {
buildAgentHookContextChannelFields,
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
runAgentHarnessAfterCompactionHook,
runAgentHarnessBeforeCompactionHook,
type AgentHarness,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
@@ -399,6 +402,20 @@ function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: false, includeAuth: false });
}
function buildCopilotCompactionHookContext(params: AgentHarnessCompactParams) {
return {
...(params.runId ? { runId: params.runId } : {}),
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
modelProviderId: params.provider,
modelId: params.model,
trigger: params.trigger,
...buildAgentHookContextChannelFields(params),
};
}
export function createCopilotAgentHarness(
options?: CreateCopilotAgentHarnessOptions,
): AgentHarness {
@@ -623,11 +640,18 @@ export function createCopilotAgentHarness(
let handle: PooledClient | undefined;
let pool: CopilotClientPool | undefined;
let activeSdkSession: CopilotHistoryCompactSession | undefined;
const hookContext = buildCopilotCompactionHookContext(params);
try {
throwIfAborted(params.abortSignal);
pool = await getPool();
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
const client = handle.client;
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
// Run the portable lifecycle hook here so both compaction paths stay observable.
await runAgentHarnessBeforeCompactionHook({
sessionFile: params.sessionFile,
ctx: hookContext,
});
compactResult = await compactWithSafetyTimeout(
(abortSignal) =>
compactTrackedSdkSession({
@@ -693,6 +717,13 @@ export function createCopilotAgentHarness(
};
}
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
if (compacted) {
await runAgentHarnessAfterCompactionHook({
sessionFile: params.sessionFile,
compactedCount: compactResult.messagesRemoved,
ctx: hookContext,
});
}
return {
ok: true,
compacted,
+19
View File
@@ -460,6 +460,25 @@ describe("runCopilotAttempt", () => {
);
});
it("reuses the precomputed legacy before_agent_start result", async () => {
const beforeAgentStart = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_agent_start", handler: beforeAgentStart }]),
);
const sdk = makeFakeSdk();
await runCopilotAttempt(
makeParams({
beforeAgentStartResult: { prependContext: "Use the cached result." },
} as never),
{ pool: makeFakePool(sdk) },
);
expect(beforeAgentStart).not.toHaveBeenCalled();
const messageOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as { prompt?: string };
expect(messageOptions.prompt).toBe("Use the cached result.\n\nhello");
});
it("preserves native Copilot SDK hooks alongside generic lifecycle hooks", async () => {
const sdk = makeFakeSdk();
const onPreToolUse = vi.fn();
+3
View File
@@ -491,6 +491,9 @@ export async function runCopilotAttempt(
developerInstructions: originalDeveloperInstructions,
messages,
ctx: hookContext,
...("beforeAgentStartResult" in input
? { beforeAgentStartResult: input.beforeAgentStartResult }
: {}),
});
const attemptInput =
promptBuild.prompt === input.prompt ? input : { ...input, prompt: promptBuild.prompt };
+2 -2
View File
@@ -15,7 +15,7 @@ import type {
* construction input so hooks do not accidentally depend on mutable raw configuration.
*/
export type AgentHarnessHookContext = {
runId: string;
runId?: string;
trace?: DiagnosticTraceContext;
jobId?: string;
agentId?: string;
@@ -39,7 +39,7 @@ export type AgentHarnessHookContext = {
/** Builds the sparse hook context object passed to agent harness plugin hooks. */
export function buildAgentHookContext(params: AgentHarnessHookContext): PluginHookAgentContext {
return {
runId: params.runId,
...(params.runId ? { runId: params.runId } : {}),
...(params.trace ? { trace: params.trace } : {}),
...(params.jobId ? { jobId: params.jobId } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from "vitest";
import { resetGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveAgentHarnessBeforePromptBuildResult } from "./prompt-compaction-hook-helpers.js";
afterEach(() => {
resetGlobalHookRunner();
});
describe("resolveAgentHarnessBeforePromptBuildResult", () => {
it("uses precomputed agent-start context without a global hook runner", async () => {
const result = await resolveAgentHarnessBeforePromptBuildResult({
prompt: "hello",
developerInstructions: "base instructions",
messages: [],
ctx: {
agentId: "agent-1",
sessionKey: "session-1",
workspaceDir: "/workspace",
},
beforeAgentStartResult: {
prependContext: "cached context",
systemPrompt: "cached instructions",
},
});
expect(result).toEqual({
prompt: "cached context\n\nhello",
developerInstructions: "cached instructions",
});
});
});
@@ -29,9 +29,15 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
developerInstructions: string;
messages: unknown[];
ctx: AgentHarnessHookContext;
beforeAgentStartResult?: PluginHookBeforeAgentStartResult;
}): Promise<AgentHarnessPromptBuildResult> {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("before_prompt_build") && !hookRunner?.hasHooks("before_agent_start")) {
const hasPrecomputedBeforeAgentStartResult = "beforeAgentStartResult" in params;
if (
!hasPrecomputedBeforeAgentStartResult &&
!hookRunner?.hasHooks("before_prompt_build") &&
!hookRunner?.hasHooks("before_agent_start")
) {
return {
prompt: params.prompt,
developerInstructions: params.developerInstructions,
@@ -45,18 +51,24 @@ export async function resolveAgentHarnessBeforePromptBuildResult(params: {
// Support the newer before_prompt_build hook plus the deprecated
// before_agent_start hook during the prompt-build migration window.
const promptBuildResult = hookRunner.hasHooks("before_prompt_build")
const promptBuildResult = hookRunner?.hasHooks("before_prompt_build")
? await hookRunner.runBeforePromptBuild(promptEvent, hookCtx).catch((error: unknown) => {
log.warn(`before_prompt_build hook failed: ${String(error)}`);
return undefined;
})
: undefined;
const beforeAgentStartResult = hookRunner.hasHooks("before_agent_start")
? await hookRunner.runBeforeAgentStart(promptEvent, hookCtx).catch((error: unknown) => {
log.warn(`deprecated before_agent_start hook failed during prompt build: ${String(error)}`);
return undefined;
})
: undefined;
// The runner resolves before_agent_start during model selection. Reuse that
// result so legacy one-shot hooks do not run twice for the same turn.
const beforeAgentStartResult = hasPrecomputedBeforeAgentStartResult
? params.beforeAgentStartResult
: hookRunner?.hasHooks("before_agent_start")
? await hookRunner.runBeforeAgentStart(promptEvent, hookCtx).catch((error: unknown) => {
log.warn(
`deprecated before_agent_start hook failed during prompt build: ${String(error)}`,
);
return undefined;
})
: undefined;
const systemPrompt = resolvePromptBuildSystemPrompt({
developerInstructions: params.developerInstructions,