From d7f747af3b451cbade41ca4533a31dc4985314fe Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:30:17 +0800 Subject: [PATCH] fix(harness): satisfy lifecycle lint gates --- .../codex/src/app-server/run-attempt.ts | 16 +++++++------- extensions/copilot/harness.test.ts | 2 +- extensions/copilot/harness.ts | 8 +++---- extensions/copilot/src/attempt.test.ts | 18 ++++++++++------ extensions/copilot/src/attempt.ts | 8 +++---- extensions/copilot/src/event-bridge.ts | 21 +++++++++++++------ extensions/copilot/src/tool-bridge.ts | 2 +- 7 files changed, 45 insertions(+), 30 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 6ffbbbd57b43..26a39d604826 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1101,12 +1101,12 @@ export async function runCodexAppServerAttempt( }; }; let promptBuild = await buildPromptFromCurrentInputs(); - const decorateCodexTurnPromptText = (promptBuild: { + const decorateCodexTurnPromptText = (promptBuildResult: { prompt: string; promptInputRange?: { start: number; end: number }; }) => { const turnPromptText = prependCodexOpenClawPromptContext( - promptBuild.prompt, + promptBuildResult.prompt, openClawPromptContext, { preservePromptWithoutContext: @@ -1115,19 +1115,19 @@ export async function runCodexAppServerAttempt( }, ); const projectedRanges = resolveShiftedPromptContextRange( - promptBuild.prompt, - promptBuild.promptInputRange, + promptBuildResult.prompt, + promptBuildResult.promptInputRange, turnPromptText, ); const preservedRange = resolveShiftedPromptInputRange( - promptBuild.prompt, - promptBuild.promptInputRange, + promptBuildResult.prompt, + promptBuildResult.promptInputRange, turnPromptText, ) ?? resolveCodexDeliveryHintPreservedInputRange({ - prompt: promptBuild.prompt, - promptInputRange: promptBuild.promptInputRange, + prompt: promptBuildResult.prompt, + promptInputRange: promptBuildResult.promptInputRange, decoratedPrompt: turnPromptText, }); return fitCodexProjectedContextForTurnStart({ diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index eac708857266..9504d8668645 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -1540,7 +1540,7 @@ describe("createCopilotAgentHarness", () => { await harness.runAttempt(makeCompactParams()); const result = await harness.compact?.(makeCompactParams()); - expect(pool.acquire).not.toHaveBeenCalled(); + expect(pool.acquire.mock.calls).toHaveLength(0); expect(result).toEqual({ ok: false, compacted: false, diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 02b0a1795e35..7be13263c4c1 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -642,10 +642,10 @@ export function createCopilotAgentHarness( cleanup: Promise; sdkSessionId: string; }) => { - const tracked = trackedSessions.get(openclawSessionId); - const stored = lookupStoredBinding(options?.sessionStore, openclawSessionId); - const ownsTrackedSession = tracked?.sdkSessionId === sdkSessionId; - const ownsStoredSession = stored?.sdkSessionId === sdkSessionId; + const trackedBinding = trackedSessions.get(openclawSessionId); + const storedBinding = lookupStoredBinding(options?.sessionStore, openclawSessionId); + const ownsTrackedSession = trackedBinding?.sdkSessionId === sdkSessionId; + const ownsStoredSession = storedBinding?.sdkSessionId === sdkSessionId; if (!ownsTrackedSession && !ownsStoredSession) { return; } diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts index ca954d350aa6..22ffcb4832db 100644 --- a/extensions/copilot/src/attempt.test.ts +++ b/extensions/copilot/src/attempt.test.ts @@ -108,6 +108,12 @@ function flushAsync() { return tick().then(tick).then(tick); } +function waitForEventLoopTurn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + function getPromptErrorCode(result: AgentHarnessAttemptResult): string | undefined { return (result.promptError as { code?: string } | undefined)?.code; } @@ -343,7 +349,7 @@ describe("runCopilotAttempt", () => { createToolBridge, pool: makeFakePool(sdk), }); - await new Promise((resolve) => setImmediate(resolve)); + await waitForEventLoopTurn(); expect(beforePromptBuild).toHaveBeenCalledWith( expect.objectContaining({ prompt: "hello" }), @@ -499,7 +505,7 @@ describe("runCopilotAttempt", () => { expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1); expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1"); - expect(pool.release).toHaveBeenCalledTimes(1); + expect(pool.release.mock.calls).toHaveLength(1); }); it("cancels retained compaction when the caller aborts after a turn result", async () => { @@ -602,7 +608,7 @@ describe("runCopilotAttempt", () => { await runCopilotAttempt(makeParams({ hooksConfig: { onUserPromptSubmitted } } as never), { pool: makeFakePool(sdk), }); - await new Promise((resolve) => setImmediate(resolve)); + await waitForEventLoopTurn(); expect(onUserPromptSubmitted).toHaveBeenCalledWith( expect.objectContaining({ prompt: "hello" }), @@ -668,7 +674,7 @@ describe("runCopilotAttempt", () => { pool: makeFakePool(sdk), }, ); - await new Promise((resolve) => setImmediate(resolve)); + await waitForEventLoopTurn(); expect(result.aborted).toBe(true); expect(sdk.sessions[0]?.sendAndWait).not.toHaveBeenCalled(); @@ -695,7 +701,7 @@ describe("runCopilotAttempt", () => { settled = true; return result; }); - await new Promise((resolve) => setImmediate(resolve)); + await waitForEventLoopTurn(); expect(agentEnd).toHaveBeenCalledTimes(1); expect(settled).toBe(false); @@ -1935,7 +1941,7 @@ describe("runCopilotAttempt", () => { expect(sdk.sessions[0]?.rpc.history.cancelBackgroundCompaction).toHaveBeenCalledTimes(1); expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); expect(sdk.client.deleteSession).toHaveBeenCalledWith("sess-1"); - expect(pool.release).toHaveBeenCalledTimes(1); + expect(pool.release.mock.calls).toHaveLength(1); }); it("cancels deferred cleanup when the timed-out caller aborts", async () => { diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index fc1af802f341..85d2f9218a48 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -723,22 +723,22 @@ export async function runCopilotAttempt( } bridge = attachEventBridge(session, { onAssistantDelta: input.onAssistantDelta, - onCompactionStart: () => { + onCompactionStart: async () => { const sessionFile = readString(input.sessionFile); if (!sessionFile) { return; } - return runAgentHarnessBeforeCompactionHook({ + await runAgentHarnessBeforeCompactionHook({ sessionFile, ctx: hookContext, }); }, - onCompactionComplete: ({ messagesRemoved, success }) => { + onCompactionComplete: async ({ messagesRemoved, success }) => { const sessionFile = readString(input.sessionFile); if (!success || !sessionFile) { return; } - return runAgentHarnessAfterCompactionHook({ + await runAgentHarnessAfterCompactionHook({ sessionFile, compactedCount: messagesRemoved ?? -1, ctx: hookContext, diff --git a/extensions/copilot/src/event-bridge.ts b/extensions/copilot/src/event-bridge.ts index 22ffb44256db..de0cdb52c590 100644 --- a/extensions/copilot/src/event-bridge.ts +++ b/extensions/copilot/src/event-bridge.ts @@ -263,12 +263,7 @@ export function attachEventBridge( return compactionChain; }, async awaitCompactionCompletion() { - // Background compaction can outlive session.idle. Keep the observer - // attached until its completion callback has run before releasing the session. - while (activeCompactionCount > 0) { - await compactionIdle; - } - await compactionChain; + await awaitStableCompaction(); }, awaitSessionIdle() { return observedSessionIdle ? Promise.resolve() : sessionIdle; @@ -338,6 +333,20 @@ export function attachEventBridge( const queued = compactionChain.then(callback, callback); compactionChain = queued.catch(() => undefined); } + + async function awaitStableCompaction(): Promise { + const idle = activeCompactionCount > 0 ? compactionIdle : undefined; + if (idle) { + await idle; + } + const callbacks = compactionChain; + await callbacks; + // Compaction events can arrive while an earlier hook callback settles. + // Recheck both queues before teardown so the root observer stays attached. + if (activeCompactionCount > 0 || compactionChain !== callbacks) { + await awaitStableCompaction(); + } + } } function buildAssistantMessage(params: { diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index d959238d24d0..526269884082 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -424,7 +424,7 @@ export function convertOpenClawToolToSdkTool( }; const notifyToolCompleted = (completion: CopilotToolCompletion) => { try { - void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error) => { + void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error: unknown) => { console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error); }); } catch (error) {