mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
This commit is contained in:
committed by
GitHub
parent
4ebaf846d9
commit
f43064d48b
@@ -754,10 +754,11 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops replayable reasoning when requested even with following content", () => {
|
||||
it("drops replayable reasoning at the switch boundary even with following content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: 2,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
@@ -772,9 +773,9 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
expect(
|
||||
downgradeOpenAIReasoningBlocks(
|
||||
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
|
||||
{ dropReplayableReasoning: true },
|
||||
{ dropReplayableReasoningBefore: 2 },
|
||||
),
|
||||
).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]);
|
||||
).toEqual([{ role: "assistant", timestamp: 2, content: [{ type: "text", text: "answer" }] }]);
|
||||
});
|
||||
|
||||
it("drops the paired message id when replayable reasoning is dropped", () => {
|
||||
@@ -799,7 +800,7 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
expect(
|
||||
downgradeOpenAIReasoningBlocks(
|
||||
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
|
||||
{ dropReplayableReasoning: true },
|
||||
{ dropReplayableReasoningBefore: 2 },
|
||||
),
|
||||
).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]);
|
||||
});
|
||||
@@ -832,6 +833,7 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
@@ -854,11 +856,12 @@ describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
expect(
|
||||
downgradeOpenAIReasoningBlocks(
|
||||
input as Parameters<typeof downgradeOpenAIReasoningBlocks>[0],
|
||||
{ dropReplayableReasoning: true },
|
||||
{ dropReplayableReasoningBefore: 2 },
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
|
||||
@@ -21,7 +21,7 @@ type OpenAIReasoningSignature = {
|
||||
};
|
||||
|
||||
type DowngradeOpenAIReasoningBlocksOptions = {
|
||||
dropReplayableReasoning?: boolean;
|
||||
dropReplayableReasoningBefore?: number;
|
||||
};
|
||||
|
||||
const OPENAI_RESPONSES_ID_MAX_LENGTH = 64;
|
||||
@@ -60,6 +60,17 @@ function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimestampMs(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFollowingNonThinkingBlock(
|
||||
content: Extract<AgentMessage, { role: "assistant" }>["content"],
|
||||
index: number,
|
||||
@@ -434,6 +445,12 @@ export function downgradeOpenAIReasoningBlocks(
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const messageTimestamp = parseTimestampMs((assistantMsg as { timestamp?: unknown }).timestamp);
|
||||
// Timestamp-less legacy entries cannot prove they belong to the new route;
|
||||
// treat them as pre-switch so stale provider ids never re-enter replay.
|
||||
const dropReplayableReasoning =
|
||||
options.dropReplayableReasoningBefore !== undefined &&
|
||||
(messageTimestamp === null || messageTimestamp <= options.dropReplayableReasoningBefore);
|
||||
|
||||
let changed = false;
|
||||
let droppedReplayableReasoning = false;
|
||||
@@ -459,7 +476,7 @@ export function downgradeOpenAIReasoningBlocks(
|
||||
nextContent.push(block);
|
||||
continue;
|
||||
}
|
||||
if (options.dropReplayableReasoning) {
|
||||
if (dropReplayableReasoning) {
|
||||
changed = true;
|
||||
droppedReplayableReasoning = true;
|
||||
continue;
|
||||
|
||||
@@ -31,11 +31,19 @@ export function makeModelSnapshotEntry(data: {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeInMemorySessionManager(entries: SessionEntry[]): SessionManager {
|
||||
export function makeInMemorySessionManager(
|
||||
entries: SessionEntry[],
|
||||
activeBranchEntries: SessionEntry[] = entries,
|
||||
): SessionManager {
|
||||
return {
|
||||
getEntries: vi.fn(() => entries),
|
||||
getBranch: vi.fn(() => activeBranchEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
entries.push({ type: "custom", customType, data });
|
||||
const entry = { type: "custom", customType, data };
|
||||
entries.push(entry);
|
||||
if (activeBranchEntries !== entries) {
|
||||
activeBranchEntries.push(entry);
|
||||
}
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
}
|
||||
@@ -43,6 +51,7 @@ export function makeInMemorySessionManager(entries: SessionEntry[]): SessionMana
|
||||
export function makeMockSessionManager(): SessionManager {
|
||||
return {
|
||||
getEntries: vi.fn().mockReturnValue([]),
|
||||
getBranch: vi.fn().mockReturnValue([]),
|
||||
appendCustomEntry: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
}
|
||||
@@ -114,6 +123,7 @@ export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise<Saniti
|
||||
export function makeReasoningAssistantMessages(opts?: {
|
||||
thinkingSignature?: "object" | "json";
|
||||
includeText?: boolean;
|
||||
timestamp?: number;
|
||||
}): AgentMessage[] {
|
||||
const thinkingSignature: unknown =
|
||||
opts?.thinkingSignature === "json"
|
||||
@@ -136,6 +146,7 @@ export function makeReasoningAssistantMessages(opts?: {
|
||||
{
|
||||
role: "assistant",
|
||||
content,
|
||||
...(opts?.timestamp === undefined ? {} : { timestamp: opts.timestamp }),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -185,7 +196,10 @@ function makeSnapshotChangedOpenAIReasoningScenario() {
|
||||
];
|
||||
return {
|
||||
sessionManager: makeInMemorySessionManager(sessionEntries),
|
||||
messages: makeReasoningAssistantMessages({ thinkingSignature: "object", includeText: true }),
|
||||
messages: makeReasoningAssistantMessages({
|
||||
thinkingSignature: "object",
|
||||
includeText: true,
|
||||
}),
|
||||
modelId: "gpt-5.4",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1241,6 +1241,106 @@ describe("sanitizeSessionHistory", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps pre-switch reasoning dropped on the switch turn and the next turn", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 100,
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
}),
|
||||
];
|
||||
const sessionManager = makeInMemorySessionManager(sessionEntries);
|
||||
const messages = [
|
||||
makeAssistantMessage(
|
||||
[
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning before the switch",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_old", type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text: "answer before the switch" },
|
||||
],
|
||||
{ timestamp: 150 },
|
||||
),
|
||||
];
|
||||
|
||||
const switchTurn = await sanitizeWithOpenAIResponses({
|
||||
sanitizeSessionHistory,
|
||||
messages,
|
||||
modelId: "gpt-5.4",
|
||||
sessionManager,
|
||||
});
|
||||
const nextTurn = await sanitizeWithOpenAIResponses({
|
||||
sanitizeSessionHistory,
|
||||
messages,
|
||||
modelId: "gpt-5.4",
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
expect((switchTurn[0] as AssistantMessage).content).toEqual([
|
||||
{ type: "text", text: "answer before the switch" },
|
||||
]);
|
||||
expect(JSON.stringify(nextTurn)).toBe(JSON.stringify(switchTurn));
|
||||
});
|
||||
|
||||
it("keeps reasoning newer than the latest actual model switch", async () => {
|
||||
const sessionEntries = [
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 100,
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
}),
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 200,
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 300,
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
];
|
||||
const makeReasoningMessage = (id: string, text: string, timestamp: number) =>
|
||||
makeAssistantMessage(
|
||||
[
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: `reasoning ${text}`,
|
||||
thinkingSignature: JSON.stringify({ id: `rs_${id}`, type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text },
|
||||
],
|
||||
{ timestamp },
|
||||
);
|
||||
const result = await sanitizeWithOpenAIResponses({
|
||||
sanitizeSessionHistory,
|
||||
messages: [
|
||||
makeReasoningMessage("old", "before switch", 150),
|
||||
makeUserMessage("after switch", 225),
|
||||
makeReasoningMessage("new", "after switch", 250),
|
||||
],
|
||||
modelId: "gpt-5.4",
|
||||
sessionManager: makeInMemorySessionManager(sessionEntries),
|
||||
});
|
||||
|
||||
expect((result[0] as AssistantMessage).content).toEqual([
|
||||
{ type: "text", text: "before switch" },
|
||||
]);
|
||||
expect((result[2] as AssistantMessage).content).toEqual([
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning after switch",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_new", type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text: "after switch" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the paired assistant message id when reasoning is dropped after a model switch", async () => {
|
||||
// Regression for issue #88019: a fallback from azure-openai-responses to a
|
||||
// non-Responses model and back must not leave an orphaned msg_* id (its
|
||||
@@ -1349,18 +1449,35 @@ describe("sanitizeSessionHistory", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps paired openai reasoning when the model snapshot stays the same", async () => {
|
||||
const sessionEntries = [
|
||||
it("keeps paired openai reasoning when the active branch never switched", async () => {
|
||||
const activeSnapshot = makeModelSnapshotEntry({
|
||||
timestamp: 100,
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.4",
|
||||
});
|
||||
const abandonedBranchSnapshots = [
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 200,
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
}),
|
||||
makeModelSnapshotEntry({
|
||||
timestamp: 300,
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
];
|
||||
const sessionManager = makeInMemorySessionManager(sessionEntries);
|
||||
const sessionManager = makeInMemorySessionManager(
|
||||
[activeSnapshot, ...abandonedBranchSnapshots],
|
||||
[activeSnapshot],
|
||||
);
|
||||
const messages = makeReasoningAssistantMessages({
|
||||
thinkingSignature: "json",
|
||||
includeText: true,
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
const result = await sanitizeWithOpenAIResponses({
|
||||
|
||||
@@ -79,6 +79,10 @@ type ModelSnapshotEntry = {
|
||||
modelApi?: string | null;
|
||||
modelId?: string;
|
||||
};
|
||||
type ModelSnapshotState = {
|
||||
lastSnapshot: ModelSnapshotEntry | null;
|
||||
latestSwitchTimestamp: number | null;
|
||||
};
|
||||
type AssistantReplayMessage = Extract<AgentMessage, { role: "assistant" }>;
|
||||
|
||||
type ProviderReplayHookParams = {
|
||||
@@ -626,23 +630,31 @@ function createProviderReplaySessionState(
|
||||
};
|
||||
}
|
||||
|
||||
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
|
||||
function readModelSnapshotState(sessionManager: SessionManager): ModelSnapshotState {
|
||||
let lastSnapshot: ModelSnapshotEntry | null = null;
|
||||
let latestSwitchTimestamp: number | null = null;
|
||||
try {
|
||||
const entries = sessionManager.getEntries();
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const entry = entries[i] as CustomEntryLike;
|
||||
for (const rawEntry of sessionManager.getBranch()) {
|
||||
const entry = rawEntry as CustomEntryLike;
|
||||
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) {
|
||||
continue;
|
||||
}
|
||||
const data = entry?.data as ModelSnapshotEntry | undefined;
|
||||
if (data && typeof data === "object") {
|
||||
return data;
|
||||
if (
|
||||
lastSnapshot &&
|
||||
!isSameModelSnapshot(lastSnapshot, data) &&
|
||||
Number.isFinite(data.timestamp)
|
||||
) {
|
||||
latestSwitchTimestamp = data.timestamp;
|
||||
}
|
||||
lastSnapshot = data;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
return { lastSnapshot: null, latestSwitchTimestamp: null };
|
||||
}
|
||||
return null;
|
||||
return { lastSnapshot, latestSwitchTimestamp };
|
||||
}
|
||||
|
||||
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
|
||||
@@ -777,15 +789,23 @@ export async function sanitizeSessionHistory(params: {
|
||||
params.modelApi === "openai-chatgpt-responses" ||
|
||||
params.modelApi === "azure-openai-responses";
|
||||
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
|
||||
const modelChanged = priorSnapshot
|
||||
? !isSameModelSnapshot(priorSnapshot, {
|
||||
timestamp: 0,
|
||||
const snapshotState = hasSnapshot
|
||||
? readModelSnapshotState(params.sessionManager)
|
||||
: { lastSnapshot: null, latestSwitchTimestamp: null };
|
||||
const priorSnapshot = snapshotState.lastSnapshot;
|
||||
const currentSnapshot: ModelSnapshotEntry | null = hasSnapshot
|
||||
? {
|
||||
timestamp: Date.now(),
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
})
|
||||
: false;
|
||||
}
|
||||
: null;
|
||||
const modelChanged =
|
||||
priorSnapshot && currentSnapshot ? !isSameModelSnapshot(priorSnapshot, currentSnapshot) : false;
|
||||
const latestModelSwitchTimestamp = modelChanged
|
||||
? currentSnapshot?.timestamp
|
||||
: snapshotState.latestSwitchTimestamp;
|
||||
const normalizedAssistantReplay = normalizeAssistantReplayContent(withInterSessionMarkers);
|
||||
const sanitizedImages = await sanitizeSessionMessagesImages(
|
||||
normalizedAssistantReplay,
|
||||
@@ -851,8 +871,10 @@ export async function sanitizeSessionHistory(params: {
|
||||
const openAISafeToolCalls = isOpenAIResponsesApi
|
||||
? downgradeOpenAIFunctionCallReasoningPairs(
|
||||
normalizeOpenAIResponsesToolCallIds(
|
||||
// Keep the pre-switch prompt prefix byte-stable: once rs_*/msg_* ids are
|
||||
// invalidated by a switch, every later replay must keep dropping them.
|
||||
downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, {
|
||||
dropReplayableReasoning: modelChanged,
|
||||
dropReplayableReasoningBefore: latestModelSwitchTimestamp ?? undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -907,13 +929,8 @@ export async function sanitizeSessionHistory(params: {
|
||||
? assertOpenAIResponsesToolUseResultInvariant(responsesProviderRepaired)
|
||||
: responsesProviderRepaired;
|
||||
|
||||
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||
appendModelSnapshot(params.sessionManager, {
|
||||
timestamp: Date.now(),
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
if (currentSnapshot && (!priorSnapshot || modelChanged)) {
|
||||
appendModelSnapshot(params.sessionManager, currentSnapshot);
|
||||
}
|
||||
|
||||
if (!policy.applyGoogleTurnOrdering) {
|
||||
|
||||
Reference in New Issue
Block a user