Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ jobs:
! -path 'src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx' \
-print0
)
# Bun-only DOM isolation guards live outside src/ and are excluded
# from Jest, so include them here explicitly.
unit_files+=("tests/ui/domIsolation.test.ts")
bun test --max-concurrency=1 --coverage --coverage-reporter=lcov "${unit_files[@]}"
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ test-integration: node_modules/.installed build-main ## Run all tests (unit + in

test-unit: node_modules/.installed build-main ## Run unit tests
@bun test src
@bun test ./tests/ui/storybook/
@bun test ./tests/ui/storybook/ ./tests/ui/domIsolation.test.ts

test: test-unit ## Alias for test-unit

Expand Down
5 changes: 3 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ module.exports = {
"\\.txt$": "<rootDir>/tests/__mocks__/textMock.js",
"\\.svg$": "<rootDir>/tests/__mocks__/svgMock.js",
},
// Storybook UI tests use bun:test and are run via `bun test`, so Jest must skip them.
testPathIgnorePatterns: ["<rootDir>/tests/ui/storybook/"],
// Storybook UI tests and the DOM isolation guards use bun:test and are run
// via `bun test`, so Jest must skip them.
testPathIgnorePatterns: ["<rootDir>/tests/ui/storybook/", "<rootDir>/tests/ui/domIsolation"],
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
// Avoid haste module collision with vscode extension
modulePathIgnorePatterns: ["<rootDir>/vscode/"],
transform: {
Expand Down
107 changes: 107 additions & 0 deletions src/node/services/agentSession.autoCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type MuxMessage,
} from "@/common/types/message";
import { GOAL_CONTINUATION_KIND } from "@/constants/goals";
import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck";
import { Ok, Err } from "@/common/types/result";
import type { Config } from "@/node/config";
import type { AIService } from "@/node/services/aiService";
Expand Down Expand Up @@ -743,6 +744,94 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
session.dispose();
});

// Boundary-less history retaining a high-usage assistant row, as after a
// partial /clear that removed only an older prefix.
async function seedBoundarylessHighUsageHistory(workspaceId: string) {
const { historyService, config, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;

const appendUser = await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-retained", "user", "retained prompt", {
timestamp: Date.now() - 2_000,
})
);
expect(appendUser.success).toBe(true);

const appendAssistant = await historyService.appendToHistory(
workspaceId,
createMuxMessage("assistant-retained", "assistant", "retained reply", {
timestamp: Date.now() - 1_000,
model: "openai:gpt-4o",
contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 },
})
);
expect(appendAssistant.success).toBe(true);

return { historyService, config };
}

function installUsageCapturingMonitor(session: AgentSession): unknown[] {
const seenUsages: unknown[] = [];
(session as unknown as { compactionMonitor: CompactionMonitor }).compactionMonitor = {
checkBeforeSend: mock((params: { usage?: unknown }) => {
seenUsages.push(params.usage);
return {
shouldShowWarning: false,
shouldForceCompact: false,
usagePercentage: 0,
thresholdPercentage: 85,
};
}),
checkMidStream: mock(() => false),
resetForNewStream: mock(() => undefined),
setThreshold: mock(() => undefined),
getThreshold: mock(() => 0.85),
} as unknown as CompactionMonitor;
return seenUsages;
}

test("history seeding restores persisted usage when no rewrite occurred", async () => {
const workspaceId = "ws-usage-seeding-baseline";
const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId);

const harness = await createAgentSessionHarness({ workspaceId, historyService, config });
const seenUsages = installUsageCapturingMonitor(harness.session);

const result = await harness.session.sendMessage("restart send", {
model: "openai:gpt-4o",
agentId: "exec",
});
expect(result.success).toBe(true);
expect(seenUsages).toHaveLength(1);
const seeded = seenUsages[0] as AutoCompactionUsageState | undefined;
expect(seeded?.lastContextUsage).toBeDefined();

harness.session.dispose();
});

test("clearUsageState suppresses history seeding until fresh provider usage arrives", async () => {
const workspaceId = "ws-usage-seeding-suppressed";
const { historyService, config } = await seedBoundarylessHighUsageHistory(workspaceId);

const harness = await createAgentSessionHarness({ workspaceId, historyService, config });
const seenUsages = installUsageCapturingMonitor(harness.session);

// Same fixture as the baseline test, but a rewrite invalidated usage:
// the retained row's contextUsage still counts removed tokens and must not reseed.
harness.session.clearUsageState();

const result = await harness.session.sendMessage("send after partial clear", {
model: "openai:gpt-4o",
agentId: "exec",
});
expect(result.success).toBe(true);
expect(seenUsages).toHaveLength(1);
expect(seenUsages[0]).toBeUndefined();

harness.session.dispose();
});

test("seeds on-send compaction usage from the active compaction epoch only", async () => {
const workspaceId = "ws-auto-compaction-seed-active-epoch";

Expand Down Expand Up @@ -854,6 +943,24 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
session.dispose();
});

test("heartbeat context reset clears stale usage before its follow-up dispatches", async () => {
const workspaceId = "ws-heartbeat-reset-clears-usage";
const { session } = await createSessionHarness({ workspaceId });

const sessionState = session as unknown as { lastUsageState?: AutoCompactionUsageState };
sessionState.lastUsageState = { totalTokens: 95_000 };

const result = await session.appendHeartbeatContextResetBoundary({
boundaryText: "Heartbeat context reset boundary",
pendingFollowUp: { text: "heartbeat follow-up", model: "openai:gpt-4o", agentId: "exec" },
});

expect(result.success).toBe(true);
expect(sessionState.lastUsageState).toBeUndefined();

session.dispose();
});

test("surfaces nested dispatch failures after mid-stream compaction interrupt", async () => {
const workspaceId = "ws-auto-compaction-mid-stream-dispatch-failure";

Expand Down
28 changes: 25 additions & 3 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ export class AgentSession {

/** Latest context-usage snapshot used for on-send compaction checks. */
private lastUsageState?: AutoCompactionUsageState;
private usageSeedingSuppressed = false;
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated

/** Prevent duplicate mid-stream compaction interrupts while we are already transitioning. */
private midStreamCompactionPending = false;
Expand Down Expand Up @@ -2737,7 +2738,9 @@ export class AgentSession {
this.workspaceId,
truncateTargetId
);
if (!truncateResult.success) {
if (truncateResult.success) {
this.clearUsageState();
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
} else {
const isMissingEditTarget =
truncateResult.error.includes("Message with ID") &&
truncateResult.error.includes("not found in history");
Expand Down Expand Up @@ -3389,6 +3392,8 @@ export class AgentSession {
return;
}

this.usageSeedingSuppressed = false;

const totalTokens = params.usage.totalTokens ?? this.lastUsageState?.totalTokens;
if (params.live) {
this.lastUsageState = {
Expand Down Expand Up @@ -3418,6 +3423,20 @@ export class AgentSession {
};
}

/**
* Invalidate cached context usage after the active provider context is
* rewritten (boundary append, truncation, history replacement): stale usage
* would make the next send auto-compact the already-rewritten context.
* History seeding is also suppressed until the provider reports fresh usage,
* because boundary-less rewrites (partial /clear) retain rows whose persisted
* contextUsage still counts removed tokens; re-seeding those would restore
* the same stale value.
*/
clearUsageState(): void {
this.lastUsageState = undefined;
this.usageSeedingSuppressed = true;
}

/**
* 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 @@ -3502,7 +3521,7 @@ export class AgentSession {
* `lastUsageState` is still undefined.
*/
private async seedUsageStateFromHistory(): Promise<void> {
if (this.lastUsageState !== undefined) {
if (this.lastUsageState !== undefined || this.usageSeedingSuppressed) {
return;
}

Expand Down Expand Up @@ -4596,6 +4615,8 @@ export class AgentSession {
return false;
}

this.clearUsageState();

// This clear bypasses WorkspaceService.replaceHistory, so announce it on the chat funnel the
// timeline already consumes: a log that cannot explain missing history defeats its purpose.
this.emitChatEvent({
Expand Down Expand Up @@ -5196,7 +5217,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 +6745,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
30 changes: 30 additions & 0 deletions src/node/services/historyService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2019,6 +2019,36 @@ describe("HistoryService", () => {
expect(msg.metadata?.historySequence).toBe(3);
});

it("strips stale contextUsage from rows retained by partial truncation", async () => {
// Filler prefix makes the newest assistant row survive a 50% cut.
await appendNumberedMessages(service, wsId, 8);
await service.appendToHistory(
wsId,
createMuxMessage("assistant-usage", "assistant", "reply", {
contextUsage: { inputTokens: 95_000, outputTokens: 100, totalTokens: 95_100 },
contextProviderMetadata: { anthropic: {} },
model: "openai:gpt-4o",
})
);

const truncateResult = await service.truncateHistory(wsId, 0.5);
expect(truncateResult.success).toBe(true);
if (truncateResult.success) {
expect(truncateResult.data.length).toBeGreaterThan(0);
}

const remaining = await service.getHistoryFromLatestBoundary(wsId);
expect(remaining.success).toBe(true);
if (remaining.success) {
const retainedAssistant = remaining.data.find((msg) => msg.id === "assistant-usage");
expect(retainedAssistant).toBeDefined();
expect(retainedAssistant?.metadata?.contextUsage).toBeUndefined();
expect(retainedAssistant?.metadata?.contextProviderMetadata).toBeUndefined();
// Only usage snapshots are sanitized; the rest of the row survives.
expect(retainedAssistant?.metadata?.model).toBe("openai:gpt-4o");
}
});

it("keeps the archive intact on a no-op percentage truncation", async () => {
await appendNumberedMessages(service, wsId, 3);
await service.appendToHistory(wsId, boundaryMessage("boundary-1", 1));
Expand Down
18 changes: 17 additions & 1 deletion src/node/services/historyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2004,7 +2004,23 @@ export class HistoryService {
}

// Keep messages after removeCount
const remainingMessages = messages.slice(removeCount);
const remainingMessages = messages.slice(removeCount).map((msg) => {
// Retained rows' contextUsage measured the pre-truncation context
// (including the removed prefix). Persisting it would reseed stale
// auto-compaction pressure, even across app restarts. Strip it; the
// next provider response reports fresh usage.
if (msg.metadata?.contextUsage === undefined) {
return msg;
}
return {
...msg,
metadata: {
...msg.metadata,
contextUsage: undefined,
contextProviderMetadata: undefined,
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
},
};
});
const deletedMessages = messages.slice(0, removeCount);
const deletedSequences = deletedMessages
.map((msg) => msg.metadata?.historySequence)
Expand Down
Loading
Loading