diff --git a/extensions/codex/src/app-server/context-engine-projection.test.ts b/extensions/codex/src/app-server/context-engine-projection.test.ts index dd88d9df0db..d78dfb26421 100644 --- a/extensions/codex/src/app-server/context-engine-projection.test.ts +++ b/extensions/codex/src/app-server/context-engine-projection.test.ts @@ -255,6 +255,30 @@ describe("projectContextEngineAssemblyForCodex", () => { expect(fitted).toContain("[truncated "); }); + it("keeps the current request when a hook appends oversized context", () => { + const before = "OpenClaw assembled context for this turn:\n\n"; + const context = `recent context ${"c".repeat(200)}`; + const request = "\n\n\nCurrent user request:\nkeep this request"; + const hookAppend = `\n\nhook context ${"h".repeat(800)}`; + const promptText = `${before}${context}${request}${hookAppend}`; + const maxChars = 420; + + const fitted = fitCodexProjectedContextForTurnStart({ + promptText, + contextRange: { start: before.length, end: before.length + context.length }, + requestRange: { + start: before.length + context.length, + end: before.length + context.length + request.length, + }, + maxChars, + }); + + expect(fitted.length).toBeLessThanOrEqual(maxChars); + expect(fitted).toContain("recent context"); + expect(fitted).toContain("Current user request:\nkeep this request"); + expect(fitted).not.toContain("hook context"); + }); + it("bounds output for a large request under the default Codex turn limit", () => { const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS; // A large assembled header prefix already over the cap forces the diff --git a/extensions/codex/src/app-server/context-engine-projection.ts b/extensions/codex/src/app-server/context-engine-projection.ts index 29984798edc..65f58c49823 100644 --- a/extensions/codex/src/app-server/context-engine-projection.ts +++ b/extensions/codex/src/app-server/context-engine-projection.ts @@ -121,6 +121,7 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: { export function fitCodexProjectedContextForTurnStart(params: { promptText: string; contextRange?: CodexProjectedContextRange; + requestRange?: CodexProjectedContextRange; maxChars?: number; }): string { const maxChars = @@ -138,6 +139,24 @@ export function fitCodexProjectedContextForTurnStart(params: { const beforeContext = params.promptText.slice(0, range.start); const context = params.promptText.slice(range.start, range.end); const afterContext = params.promptText.slice(range.end); + const requestRange = normalizeProjectedContextRange( + params.requestRange, + params.promptText.length, + ); + if ( + requestRange && + requestRange.start >= range.end && + requestRange.end < params.promptText.length + ) { + const request = params.promptText.slice(requestRange.start, requestRange.end); + if (request.length >= maxChars) { + return truncateOlderContext(request, maxChars); + } + const contextBudget = maxChars - request.length; + const fittedContext = truncateOlderContext(context, contextBudget); + const beforeContextBudget = maxChars - fittedContext.length - request.length; + return `${truncateOlderContext(beforeContext, beforeContextBudget)}${fittedContext}${request}`; + } const contextBudget = maxChars - beforeContext.length - afterContext.length; if (contextBudget > 0) { const fittedContext = truncateOlderContext(context, contextBudget); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index dc9b10d136c..3661d4c6e6b 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1032,7 +1032,12 @@ export async function runCodexAppServerAttempt( prompt: string, promptInputRange: { start: number; end: number } | undefined, turnPromptText: string, - ): CodexProjectedContextRange | undefined => { + ): + | { + contextRange: CodexProjectedContextRange; + requestRange: CodexProjectedContextRange; + } + | undefined => { const promptTextInputOffset = promptInputRange ? promptInputRange.end - promptText.length : undefined; @@ -1059,10 +1064,17 @@ export async function runCodexAppServerAttempt( return undefined; } const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset; - return { + const contextRange = { start: turnPromptOffset + promptContextRange.start, end: turnPromptOffset + promptContextRange.end, }; + return { + contextRange, + requestRange: { + start: contextRange.end, + end: turnPromptOffset + promptTextOffset + promptText.length, + }, + }; }; let promptBuild = await buildPromptFromCurrentInputs(); const decorateCodexTurnPromptText = (promptBuild: { @@ -1078,13 +1090,15 @@ export async function runCodexAppServerAttempt( params.bootstrapContextRunKind === "cron", }, ); + const projectedRanges = resolveShiftedPromptContextRange( + promptBuild.prompt, + promptBuild.promptInputRange, + turnPromptText, + ); return fitCodexProjectedContextForTurnStart({ promptText: turnPromptText, - contextRange: resolveShiftedPromptContextRange( - promptBuild.prompt, - promptBuild.promptInputRange, - turnPromptText, - ), + contextRange: projectedRanges?.contextRange, + requestRange: projectedRanges?.requestRange, }); }; let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild); diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index f57c60684d8..a3b72f14868 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -625,6 +625,69 @@ describe("createCopilotAgentHarness", () => { expect(sessionStore.entries.get("oc-reset-race")?.sdkSessionId).toBe("sdk-sess-replacement"); }); + it("does not reuse a reset target while deferred cleanup is pending", async () => { + const cleanup = createDeferred<"aborted" | "completed" | "deadline">(); + const abort = vi.fn(); + const replacementDeleteSession = vi.fn().mockResolvedValue(undefined); + const duringResetDeleteSession = vi.fn().mockResolvedValue(undefined); + const sessionStore = makeSessionStoreMock(); + let attempt = 0; + mocks.runCopilotAttempt.mockImplementation(async (params, deps) => { + attempt += 1; + if (attempt === 1) { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-before-reset", + pooledClient: { key: {} as any, client: {} as any }, + sessionConfig: TEST_SESSION_CONFIG, + }); + deps.onDeferredCompaction?.({ + abort, + cleanup: cleanup.promise, + sdkSessionId: "sdk-sess-before-reset", + }); + } else if (attempt === 2) { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-replacement", + pooledClient: { + key: {} as any, + client: { deleteSession: replacementDeleteSession } as any, + }, + sessionConfig: TEST_SESSION_CONFIG, + }); + } else if (attempt === 3 && !params.initialReplayState?.sdkSessionId) { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-during-reset", + pooledClient: { + key: {} as any, + client: { deleteSession: duringResetDeleteSession } as any, + }, + sessionConfig: TEST_SESSION_CONFIG, + }); + } + return ATTEMPT_RESULT; + }); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + const params = { ...ATTEMPT_PARAMS, sessionId: "oc-reset-reuse" }; + + await harness.runAttempt(params); + await harness.runAttempt(params); + const reset = harness.reset?.({ sessionId: "oc-reset-reuse" }); + await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()); + await harness.runAttempt(params); + cleanup.resolve("aborted"); + await reset; + + expect( + mocks.runCopilotAttempt.mock.calls[2]?.[0]?.initialReplayState?.sdkSessionId, + ).toBeUndefined(); + expect(replacementDeleteSession).toHaveBeenCalledWith("sdk-sess-replacement"); + expect(duringResetDeleteSession).not.toHaveBeenCalled(); + expect(sessionStore.entries.get("oc-reset-reuse")?.sdkSessionId).toBe("sdk-sess-during-reset"); + }); + describe("session reuse across turns (dogfood finding #4)", () => { // These tests pin the harness's session-reuse contract: subsequent // `runAttempt` calls within the same OpenClaw session should pass diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 1542d5aba3f..e6aa2bde8ec 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -573,12 +573,13 @@ export function createCopilotAgentHarness( const currentCompactKey = computeSessionCompactKey(params); const compactionCleanupPending = openclawSessionId !== undefined && hasPendingDeferredCompactionCleanup(openclawSessionId); + const replayBlocked = + openclawSessionId !== undefined && + (compactionCleanupPending || resetBlockedStoredSessions.has(openclawSessionId)); const tracked = - openclawSessionId && !compactionCleanupPending - ? trackedSessions.get(openclawSessionId) - : undefined; + openclawSessionId && !replayBlocked ? trackedSessions.get(openclawSessionId) : undefined; const stored = openclawSessionId - ? compactionCleanupPending || resetBlockedStoredSessions.has(openclawSessionId) + ? replayBlocked ? undefined : lookupStoredBinding(options?.sessionStore, openclawSessionId) : undefined;