mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
Drop stale history responses on revision drift
This commit is contained in:
@@ -18,7 +18,10 @@ export type HistoryRequestIntent =
|
||||
export type HistoryResponseDisposition =
|
||||
| {
|
||||
kind: "drop";
|
||||
reason: "session-key-changed" | "session-epoch-changed";
|
||||
reason:
|
||||
| "session-key-changed"
|
||||
| "session-epoch-changed"
|
||||
| "transcript-revision-changed";
|
||||
}
|
||||
| {
|
||||
kind: "apply";
|
||||
@@ -86,6 +89,10 @@ export const resolveHistoryResponseDisposition = (params: {
|
||||
if ((latest.sessionEpoch ?? 0) !== params.requestEpoch) {
|
||||
return { kind: "drop", reason: "session-epoch-changed" };
|
||||
}
|
||||
const latestRevision = latest.transcriptRevision ?? latest.outputLines.length;
|
||||
if (latestRevision !== params.requestRevision) {
|
||||
return { kind: "drop", reason: "transcript-revision-changed" };
|
||||
}
|
||||
return { kind: "apply" };
|
||||
};
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("historyLifecycleWorkflow integration", () => {
|
||||
status: "idle",
|
||||
runId: null,
|
||||
outputLines: ["> question", "final answer", "final answer"],
|
||||
transcriptRevision: 6,
|
||||
transcriptRevision: 5,
|
||||
});
|
||||
|
||||
const result = runPageHistoryAdapter({
|
||||
|
||||
@@ -146,6 +146,30 @@ describe("historyLifecycleWorkflow", () => {
|
||||
kind: "drop",
|
||||
reason: "session-epoch-changed",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ transcriptRevision: 12 }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 11,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "transcript-revision-changed",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ outputLines: ["one", "two"] }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 1,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "transcript-revision-changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies history even while run is still active", () => {
|
||||
@@ -178,6 +202,21 @@ describe("historyLifecycleWorkflow", () => {
|
||||
).toEqual({
|
||||
kind: "apply",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
outputLines: ["> q1", "a1"],
|
||||
}),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 2,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "apply",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds metadata patch with truncation semantics", () => {
|
||||
|
||||
@@ -449,4 +449,105 @@ describe("historySyncOperation integration", () => {
|
||||
)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops stale history response when transcript revision changes after request", async () => {
|
||||
const requestAgent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: ["> local question", "assistant current"],
|
||||
lastResult: "assistant current",
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: "assistant current",
|
||||
lastUserMessage: "local question",
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [],
|
||||
transcriptRevision: 7,
|
||||
transcriptSequenceCounter: 0,
|
||||
sessionEpoch: 0,
|
||||
};
|
||||
const latestAgent: AgentState = {
|
||||
...requestAgent,
|
||||
transcriptRevision: 8,
|
||||
};
|
||||
let readCount = 0;
|
||||
const inFlightSessionKeys = new Set<string>();
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: requestAgent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "stale remote answer" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: requestAgent.agentId,
|
||||
getAgent: () => {
|
||||
readCount += 1;
|
||||
return readCount <= 1 ? requestAgent : latestAgent;
|
||||
},
|
||||
inFlightSessionKeys,
|
||||
requestId: "req-revision-drop-1",
|
||||
loadedAt: 16_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toEqual({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { lastHistoryRequestRevision: 7 },
|
||||
});
|
||||
|
||||
const staleDropMetrics = commands.filter(
|
||||
(entry) => entry.kind === "logMetric" && entry.metric === "history_response_dropped_stale"
|
||||
);
|
||||
expect(staleDropMetrics).toEqual([
|
||||
{
|
||||
kind: "logMetric",
|
||||
metric: "history_response_dropped_stale",
|
||||
meta: {
|
||||
reason: "transcript_revision_changed",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-revision-drop-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
updates.some((entry) => {
|
||||
const patch = entry.patch;
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(patch, "outputLines") ||
|
||||
Object.prototype.hasOwnProperty.call(patch, "lastAppliedHistoryRequestId")
|
||||
);
|
||||
})
|
||||
).toBe(false);
|
||||
expect(inFlightSessionKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe("historySyncOperation", () => {
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-4");
|
||||
});
|
||||
|
||||
it("still applies history when transcript revision changes during fetch", async () => {
|
||||
it("drops stale history when transcript revision changes during fetch", async () => {
|
||||
const requestAgent = createAgent({
|
||||
transcriptRevision: 7,
|
||||
outputLines: ["> local question", "assistant current"],
|
||||
@@ -311,16 +311,31 @@ describe("historySyncOperation", () => {
|
||||
});
|
||||
|
||||
const metrics = getCommandsByKind(commands, "logMetric");
|
||||
expect(metrics).toEqual([]);
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
kind: "logMetric",
|
||||
metric: "history_response_dropped_stale",
|
||||
meta: {
|
||||
reason: "transcript_revision_changed",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-5",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
expect(updates).toContainEqual({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { lastHistoryRequestRevision: 7 },
|
||||
});
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch.outputLines).toContain("> local question");
|
||||
expect(patch.outputLines).toContain("assistant current");
|
||||
expect(patch.outputLines).toContain("stale remote answer");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-5");
|
||||
expect(patch).not.toHaveProperty("outputLines");
|
||||
expect(patch).not.toHaveProperty("lastAppliedHistoryRequestId");
|
||||
expect(patch).not.toHaveProperty("lastResult");
|
||||
expect(patch).not.toHaveProperty("latestPreview");
|
||||
expect(inFlight.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user