fix(harness): protect reset and prompt bounds

This commit is contained in:
Vincent Koc
2026-06-21 00:47:58 +08:00
committed by Vincent Koc
parent 43e8c29fbf
commit 3968fea383
5 changed files with 132 additions and 11 deletions

View File

@@ -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<conversation_context>\n";
const context = `recent context ${"c".repeat(200)}`;
const request = "\n</conversation_context>\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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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;