Skip to content
Open
Show file tree
Hide file tree
Changes from 32 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
85 changes: 69 additions & 16 deletions src/node/services/historyService.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 * 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 +48,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 @@ -2037,6 +2029,67 @@ 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();
}
});

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("hasHistory sees archive-only workspaces", async () => {
await appendNumberedMessages(service, wsId, 1);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
Expand Down
94 changes: 76 additions & 18 deletions src/node/services/historyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { safeStringifyForCounting } from "@/common/utils/tokens/safeStringifyFor
import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy";
import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary";
import {
findLatestContextBoundaryIndex,
getContextBoundaryKind,
isDurableCompactedMarker,
isDurableContextBoundaryMarker,
} from "@/common/utils/messages/compactionBoundary";
Expand All @@ -41,6 +43,32 @@ function hasDurableCompactionBoundary(metadata: MuxMetadata | undefined): boolea
return isPositiveInteger(metadata.compactionEpoch);
}

function prefixCutChangesActiveContext(messages: MuxMessage[], removeCount: number): boolean {
const boundaryIndex = findLatestContextBoundaryIndex(messages);
if (boundaryIndex < 0) {
return removeCount > 0;
}
const activeStart =
getContextBoundaryKind(messages[boundaryIndex]) === CONTEXT_BOUNDARY_KINDS.RESET
? boundaryIndex + 1
: boundaryIndex;
return removeCount > activeStart;
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}

function stripContextUsage(message: MuxMessage): MuxMessage {
if (!message.metadata) {
return message;
}
return {
...message,
metadata: {
...message.metadata,
contextUsage: undefined,
contextProviderMetadata: undefined,
},
};
}

function getCompactionMetadataToPreserve(
workspaceId: string,
existingMessage: MuxMessage,
Expand Down Expand Up @@ -1800,6 +1828,8 @@ export class HistoryService {
const historyPath = this.getChatHistoryPath(workspaceId);
const historyEntries = this.serializeHistoryEntries(truncatedMessages, workspaceId);

const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId);

// Atomic write prevents corruption if app crashes mid-write
await writeFileAtomic(historyPath, historyEntries);

Expand Down Expand Up @@ -1829,7 +1859,6 @@ export class HistoryService {
// truncation. When the truncation empties the active file, floor the
// counter with the archive max so new appends can never reuse archived
// sequence numbers.
const archiveMaxSeq = await this.getArchiveTailMaxSequence(workspaceId);
const nextSeq = Math.max(maxTruncatedSeq, archiveMaxSeq) + 1;
assert(
isNonNegativeInteger(nextSeq),
Expand Down Expand Up @@ -1869,11 +1898,25 @@ export class HistoryService {
keepTargetMessage ? messageIndex + 1 : messageIndex
);

const historyPath = this.getChatHistoryPath(workspaceId);
const archivePath = this.getChatArchivePath(workspaceId);
await fs.rm(historyPath, { force: true });
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
await writeFileAtomic(
this.getChatHistoryPath(workspaceId),
this.serializeHistoryEntries(truncatedMessages, workspaceId)
archivePath,
this.serializeHistoryEntries(truncatedMessages.map(stripContextUsage), workspaceId)
);
await fs.rm(this.getChatArchivePath(workspaceId), { force: true });
await fs.rename(archivePath, historyPath);
try {
await writeFileAtomic(
historyPath,
this.serializeHistoryEntries(truncatedMessages, workspaceId)
);
} catch (error) {
log.warn("Failed to restore usage after archived history truncation", {
workspaceId,
error,
});
}
// chat.jsonl may contain sealed epochs again — allow the lazy check to re-run.
this.sealedRotationChecked.delete(workspaceId);

Expand Down Expand Up @@ -1950,10 +1993,9 @@ export class HistoryService {
// Structural rewrite requires full history content (oldest rows live in
// the sealed archive). Percentage truncation is a rare recovery path
// (compaction-failure retry), so the O(total-history) read is acceptable.
const messages = [
...(await this.readArchivedHistory(workspaceId)),
...(await this.readChatHistory(workspaceId)),
];
const archivedMessages = await this.readArchivedHistory(workspaceId);
const chatMessages = await this.readChatHistory(workspaceId);
const messages = [...archivedMessages, ...chatMessages];
if (messages.length === 0) {
return Ok([]); // Nothing to truncate
}
Expand Down Expand Up @@ -2003,21 +2045,37 @@ export class HistoryService {
return Ok(deletedSequences);
}

// Keep messages after removeCount
const remainingMessages = messages.slice(removeCount);
const activeContextChanged = prefixCutChangesActiveContext(messages, removeCount);
const sanitize = activeContextChanged
? stripContextUsage
: (message: MuxMessage) => message;
const remainingMessages = messages.slice(removeCount).map(sanitize);
const deletedMessages = messages.slice(0, removeCount);
const deletedSequences = deletedMessages
.map((msg) => msg.metadata?.historySequence)
.filter((s): s is number => isNonNegativeInteger(s));
const remainingArchiveCount = Math.max(0, archivedMessages.length - removeCount);
const remainingArchive = remainingMessages.slice(0, remainingArchiveCount);
const remainingChat = remainingMessages.slice(remainingArchiveCount);

// Collapse the remainder into chat.jsonl and drop the archive (the cut
// may fall anywhere inside it). It may contain old boundaries; a later
// boundary write re-seals it.
const historyEntries = this.serializeHistoryEntries(remainingMessages, workspaceId);

// Atomic write prevents corruption if app crashes mid-write
await writeFileAtomic(historyPath, historyEntries);
await fs.rm(archivePath, { force: true });
if (activeContextChanged && archivedMessages.length > 0) {
await writeFileAtomic(
historyPath,
this.serializeHistoryEntries(chatMessages.map(stripContextUsage), workspaceId)
);
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}
if (remainingArchive.length > 0) {
await writeFileAtomic(
archivePath,
this.serializeHistoryEntries(remainingArchive, workspaceId)
);
} else {
await fs.rm(archivePath, { force: true });
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}
await writeFileAtomic(
historyPath,
this.serializeHistoryEntries(remainingChat, workspaceId)
);
this.sealedRotationChecked.delete(workspaceId);

// Update sequence counter to continue from where we are.
Expand Down
Loading
Loading