Drop stale history responses on revision drift

This commit is contained in:
George Pickett
2026-02-25 09:30:05 -08:00
parent 87c1ebd075
commit a7cb00b8c8
5 changed files with 170 additions and 8 deletions
@@ -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);
});
});
+21 -6
View File
@@ -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);
});
});