mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(harness): cover manual lifecycle hooks
This commit is contained in:
@@ -1020,6 +1020,9 @@ export async function runCodexAppServerAttempt(
|
||||
developerInstructions,
|
||||
messages: codexModelInputHistoryMessages,
|
||||
ctx: hookContext,
|
||||
...("beforeAgentStartResult" in params
|
||||
? { beforeAgentStartResult: params.beforeAgentStartResult }
|
||||
: {}),
|
||||
});
|
||||
const resolveShiftedPromptContextRange = (
|
||||
prompt: string,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user