Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
258b389
🤖 fix: clear stale usage state when history is rewritten to a fresh b…
ibetitsmike Aug 7, 2026
cfb05fb
🤖 fix: clear stale usage on remaining history-rewrite paths
ibetitsmike Aug 7, 2026
e3e6dcd
🤖 tests: self-heal poisoned DOM globals and pre-cache react-dnd in te…
ibetitsmike Aug 7, 2026
fadf52b
🤖 tests: exclude bun-only DOM isolation guards from Jest
ibetitsmike Aug 7, 2026
37ced8c
🤖 fix: suppress usage seeding after history rewrites until fresh prov…
ibetitsmike Aug 7, 2026
acc9f3d
🤖 tests: run the bun-only DOM isolation guards in test-unit and CI Unit
ibetitsmike Aug 7, 2026
b25223c
🤖 fix: strip stale contextUsage from rows retained by partial truncation
ibetitsmike Aug 7, 2026
f9dd1ad
🤖 fix: re-enable usage seeding when a heartbeat reset rolls back
ibetitsmike Aug 7, 2026
db716c5
🤖 fix: clear usage inside the truncate step, before wake restoration
ibetitsmike Aug 7, 2026
c6704db
🤖 fix: keep history seeding available after message-edit truncation
ibetitsmike Aug 7, 2026
23fc92d
🤖 fix: preserve usage when a partial truncation stays before the late…
ibetitsmike Aug 7, 2026
fc725ae
fix: re-enable usage seeding when an edit restores a pre-reset prefix
ibetitsmike Aug 7, 2026
f0476c2
fix: keep usage valid when a cut ends at a provider-invisible reset b…
ibetitsmike Aug 7, 2026
c273f98
fix: keep usage seeding enabled after compaction so the fresh boundar…
ibetitsmike Aug 7, 2026
344d7fe
fix: order truncation's two-file rewrite so failures never change the…
ibetitsmike Aug 7, 2026
4999404
test: restore the real Dialog module after every suite that stubs it
ibetitsmike Aug 7, 2026
f3f4a64
Merge remote-tracking branch 'origin/main' into chat-compact-6mxx
ibetitsmike Aug 7, 2026
626a0d8
fix: preserve usage when a cut removes only provider-ineligible activ…
ibetitsmike Aug 7, 2026
e843e67
fix: truncate archive and chat.jsonl in place so no crash or failure …
ibetitsmike Aug 7, 2026
2a483a2
fix: ignore workflow display-only rows when detecting active-context …
ibetitsmike Aug 7, 2026
e11580c
fix: strip persisted usage before any truncation step can change the …
ibetitsmike Aug 7, 2026
aca3df9
fix: roll back pre-cut usage sanitization when the truncation cut fai…
ibetitsmike Aug 7, 2026
bb00503
fix: only treat archive deletion as a window change when the archive …
ibetitsmike Aug 7, 2026
f2cee19
fix: notify usage invalidation at commit time so a failed cut cannot …
ibetitsmike Aug 7, 2026
b58b5da
fix: skip pre-cut usage sanitization when only the chat cut changes t…
ibetitsmike Aug 7, 2026
b59a3fe
fix: clear usage at the edit-truncation commit point so a partial com…
ibetitsmike Aug 8, 2026
5d1e3eb
fix: keep history seeding suppressed when an archived edit partially …
ibetitsmike Aug 8, 2026
c9bb86a
fix: report committed deletions at commit time so failed cuts cannot …
ibetitsmike Aug 8, 2026
170a0d9
fix: make the archived-edit duplicated-prefix state restart-safe by s…
ibetitsmike Aug 8, 2026
2a5274d
fix: re-enable usage seeding when a committed active-file edit fails …
ibetitsmike Aug 8, 2026
1899e9f
fix: simplify stale usage invalidation after history rewrites
ibetitsmike Aug 8, 2026
ac9a325
tests: remove unrelated review-driven coverage
ibetitsmike Aug 8, 2026
1db6e95
fix: make history truncation failures recoverable
ibetitsmike Aug 8, 2026
f99fc2b
🤖 fix: recover interrupted history truncations
ibetitsmike Aug 8, 2026
1722f48
🤖 fix: avoid nested workspace history locks
ibetitsmike Aug 8, 2026
af4b86b
🤖 fix: snapshot fork history under the source lock
ibetitsmike Aug 8, 2026
61078d3
🤖 fix: stabilize refresh and async service tests
ibetitsmike Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2733,6 +2733,7 @@ export class AgentSession {
// when the edit target is outside the active context window.
const truncateTargetId = await this.getEditTruncateTargetId(editMessageId);

this.clearUsageState();
const truncateResult = await this.historyService.truncateAfterMessage(
this.workspaceId,
truncateTargetId
Expand Down Expand Up @@ -3418,6 +3419,11 @@ export class AgentSession {
};
}

/** Prevent cached usage from auto-compacting a rewritten context. */
clearUsageState(): void {
this.lastUsageState = undefined;
}

/**
* Persist a manual user message + emit a stream-error chat event when a
* pre-stream gate (e.g. the unpriced-model budget gate) rejects a send.
Expand Down Expand Up @@ -4583,6 +4589,7 @@ export class AgentSession {
});
}

this.clearUsageState();
const clearResult = this.clearHistoryForHardRestart
? await this.clearHistoryForHardRestart({
monitorHistoryLockHeld: context.monitorHistoryLockHeld === true,
Expand Down Expand Up @@ -5196,7 +5203,7 @@ export class AgentSession {

// Compaction collapses history to a boundary summary, so prior context-usage snapshots
// are stale. Clear them to prevent immediate re-trigger loops on the follow-up turn.
this.lastUsageState = undefined;
this.clearUsageState();
Comment thread
ibetitsmike marked this conversation as resolved.

if (completedCompactionRequest?.source === "auto-compaction") {
this.emitChatEvent({
Expand Down Expand Up @@ -6724,6 +6731,7 @@ export class AgentSession {
pendingFollowUp: params.pendingFollowUp,
});
if (result.success) {
this.clearUsageState();
Comment thread
ibetitsmike marked this conversation as resolved.
this.onPostCompactionStateChange?.();
}
return result;
Expand Down
209 changes: 185 additions & 24 deletions src/node/services/historyService.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary";
import { HistoryService } from "./historyService";
import { Config } from "@/node/config";
import type { Config } from "@/node/config";
import { createTestHistoryService } from "./testHistoryService";
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
import assert from "node:assert";
import { createHash } from "node:crypto";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";

/** Collect all messages via iterateFullHistory (replaces removed getFullHistory). */
async function collectFullHistory(service: HistoryService, workspaceId: string) {
Expand Down Expand Up @@ -48,25 +49,17 @@ async function appendNumberedMessages(
describe("HistoryService", () => {
let service: HistoryService;
let config: Config;
let tempDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
// Create a temporary directory for test files
tempDir = path.join(os.tmpdir(), `mux-test-${Date.now()}-${Math.random()}`);
await fs.mkdir(tempDir, { recursive: true });

// Create a Config with the temp directory
config = new Config(tempDir);
service = new HistoryService(config);
const testService = await createTestHistoryService();
service = testService.historyService;
config = testService.config;
cleanup = testService.cleanup;
});

afterEach(async () => {
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
await cleanup();
});

describe("getHistory", () => {
Expand Down Expand Up @@ -1287,18 +1280,15 @@ describe("HistoryService", () => {
const scanStarted = new Promise<void>((resolve) => {
markScanStarted = resolve;
});
const originalIterateFullHistory: HistoryService["iterateFullHistory"] =
service.iterateFullHistory.bind(service);
const blockingIterateFullHistory: HistoryService["iterateFullHistory"] = async (
workspaceIdArg,
direction,
visitor
) => {
const internal = service as unknown as {
iterateFullHistoryUnlocked: HistoryService["iterateFullHistory"];
};
const originalIterateFullHistory = internal.iterateFullHistoryUnlocked.bind(service);
internal.iterateFullHistoryUnlocked = async (workspaceIdArg, direction, visitor) => {
markScanStarted();
await scanReleased;
return originalIterateFullHistory(workspaceIdArg, direction, visitor);
};
service.iterateFullHistory = blockingIterateFullHistory;

const scan = service.getMessagesForCompactionEpoch(workspaceId, {
workspaceId,
Expand Down Expand Up @@ -2037,6 +2027,177 @@ describe("HistoryService", () => {
expect(await fs.readFile(archivePath(wsId), "utf-8")).toBe(archiveBefore);
});

it("does not reseed usage from before a partial prefix truncation", async () => {
await appendNumberedMessages(service, wsId, 8);
await service.appendToHistory(
wsId,
createMuxMessage("assistant-usage", "assistant", "reply", {
contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 },
contextProviderMetadata: { openai: {} },
model: "openai:gpt-4o",
})
);
await service.appendToHistory(
wsId,
createMuxMessage("assistant-provider-metadata", "assistant", "reply", {
contextProviderMetadata: { openai: {} },
model: "openai:gpt-4o",
})
);

const truncateResult = await service.truncateHistory(wsId, 0.5);
expect(truncateResult.success).toBe(true);

const restarted = new HistoryService(config);
const remaining = await restarted.getHistoryFromLatestBoundary(wsId);
expect(remaining.success).toBe(true);
if (remaining.success) {
const retainedAssistant = remaining.data.find(
(message) => message.id === "assistant-usage"
);
expect(retainedAssistant).toBeDefined();
expect(retainedAssistant?.metadata?.contextUsage).toBeUndefined();
expect(retainedAssistant?.metadata?.contextProviderMetadata).toBeUndefined();
const providerMetadataOnly = remaining.data.find(
(message) => message.id === "assistant-provider-metadata"
);
expect(providerMetadataOnly).toBeDefined();
expect(providerMetadataOnly?.metadata?.contextProviderMetadata).toBeUndefined();
}
});

async function expectWorkflowDisplayTruncationPreservesUsage(withResetBoundary: boolean) {
if (withResetBoundary) {
await appendNumberedMessages(service, wsId, 12);
await service.appendToHistory(
wsId,
createMuxMessage("reset-boundary", "assistant", "", {
contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET,
})
);
}
await service.appendToHistory(
wsId,
createMuxMessage(
"workflow-display",
"user",
`workflow trigger display ${"x".repeat(2_000)}`,
{ muxMetadata: { type: "workflow-trigger-display", rawCommand: "/wf", runId: "run-1" } }
)
);
await service.appendToHistory(wsId, createMuxMessage("user-active", "user", "prompt"));
await service.appendToHistory(
wsId,
createMuxMessage("assistant-active", "assistant", "active reply", {
contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 },
model: "openai:gpt-4o",
})
);

expect((await service.truncateHistory(wsId, 0.5)).success).toBe(true);

const active = await service.getHistoryFromLatestBoundary(wsId);
expect(active.success).toBe(true);
if (active.success) {
expect(active.data.find((message) => message.id === "workflow-display")).toBeUndefined();
const retainedAssistant = active.data.find((message) => message.id === "assistant-active");
expect(retainedAssistant).toBeDefined();
expect(retainedAssistant?.metadata?.contextUsage).toMatchObject({ inputTokens: 95_000 });
}
}

it("preserves active usage when uncompacted truncation removes only workflow display rows", () =>
expectWorkflowDisplayTruncationPreservesUsage(false));

it("preserves active usage when truncation removes only workflow display rows", () =>
expectWorkflowDisplayTruncationPreservesUsage(true));

it("preserves active usage when truncation removes only sealed rows", async () => {
await appendNumberedMessages(service, wsId, 8);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
await service.appendToHistory(
wsId,
createMuxMessage("active-usage", "assistant", "reply", {
contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 },
model: "openai:gpt-4o",
})
);

expect((await service.truncateHistory(wsId, 0.2)).success).toBe(true);

const active = await service.getHistoryFromLatestBoundary(wsId);
expect(active.success).toBe(true);
if (active.success) {
expect(
active.data.find((message) => message.id === "active-usage")?.metadata?.contextUsage
).toBeDefined();
}
});

it("restores a markerless archive tombstone left by an older truncation", async () => {
await appendNumberedMessages(service, wsId, 3);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after"));
await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`);

const restarted = new HistoryService(config);
const full = await collectFullHistory(restarted, wsId);
expect(full.map((message) => message.id)).toEqual([
"msg-0",
"msg-1",
"msg-2",
"boundary-1",
"post-0",
]);
});

it("restores an interrupted archive tombstone when only the final chat matches", async () => {
await appendNumberedMessages(service, wsId, 3);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after"));
const chatContents = await fs.readFile(chatPath(wsId), "utf-8");
const hash = (contents: string) => createHash("sha256").update(contents).digest("hex");
await fs.writeFile(
`${archivePath(wsId)}.truncate.json`,
JSON.stringify({
phase: "prepared",
finalArchiveHash: hash("replacement archive\n"),
finalChatHash: hash(chatContents),
})
);
await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`);

const restarted = new HistoryService(config);
const full = await collectFullHistory(restarted, wsId);
expect(full.map((message) => message.id)).toEqual([
"msg-0",
"msg-1",
"msg-2",
"boundary-1",
"post-0",
]);
});

it("does not restore a committed archive tombstone before appending", async () => {
await appendNumberedMessages(service, wsId, 3);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
await service.appendToHistory(wsId, createMuxMessage("post-0", "user", "after"));
await fs.writeFile(
`${archivePath(wsId)}.truncate.json`,
JSON.stringify({ finalArchiveHash: null, finalChatHash: null })
);
await fs.rename(archivePath(wsId), `${archivePath(wsId)}.truncate`);
await fs.rm(chatPath(wsId));

const restarted = new HistoryService(config);
const message = createMuxMessage("new-msg", "user", "fresh");
expect((await restarted.appendToHistory(wsId, message)).success).toBe(true);
expect(message.metadata?.historySequence).toBe(0);
expect((await collectFullHistory(restarted, wsId)).map((item) => item.id)).toEqual([
"new-msg",
]);
});

it("hasHistory sees archive-only workspaces", async () => {
await appendNumberedMessages(service, wsId, 1);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
Expand Down
Loading
Loading