Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 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
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
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
19 changes: 19 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 @@ -854,6 +855,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
19 changes: 17 additions & 2 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2737,7 +2737,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 @@ -3418,6 +3420,16 @@ 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. The
* next send re-seeds from post-rewrite history.
*/
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 @@ -4596,6 +4608,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 +5210,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 +6738,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
181 changes: 181 additions & 0 deletions src/node/services/workspaceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, expect, test, mock, beforeEach, afterEach, spyOn, type Mock }
import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./workspaceService";
import type { IdleCompactionOutcome } from "./idleCompactionService";
import type { AgentSession } from "./agentSession";
import { createAgentSessionHarness } from "./agentSession.testHarness";
import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck";
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
import { askUserQuestionManager } from "./askUserQuestionManager";
import { WorkspaceLifecycleHooks } from "./workspaceLifecycleHooks";
import { EventEmitter } from "events";
Expand Down Expand Up @@ -4273,6 +4276,184 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => {
}
});

test("start-here replacement clears stale usage so the next send does not auto-compact", async () => {
const { config, historyService, workspaceService, cleanup } = await createServices();
const workspaceId = "start-here-clears-usage-state";
const streamMessage = mock((..._args: unknown[]) => Promise.resolve(Ok(undefined)));
const harness = await createAgentSessionHarness({
workspaceId,
config,
historyService,
aiServiceOverrides: {
streamMessage: streamMessage as unknown as AIService["streamMessage"],
},
});
try {
await config.addWorkspace("/tmp/start-here-usage-project", {
id: workspaceId,
name: workspaceId,
projectName: "start-here-usage-project",
projectPath: "/tmp/start-here-usage-project",
runtimeConfig: { type: "local" },
});
expect(
(
await historyService.appendToHistory(
workspaceId,
createMuxMessage("pre-start-here-user", "user", "long conversation", {})
)
).success
).toBe(true);
expect(
(
await historyService.appendToHistory(
workspaceId,
createMuxMessage("pre-start-here-assistant", "assistant", "long reply", {
model: "openai:gpt-4o",
contextUsage: { inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 },
})
)
).success
).toBe(true);

(workspaceService as unknown as { sessions: Map<string, AgentSession> }).sessions.set(
workspaceId,
harness.session
);
// In-memory snapshot the previous stream left behind: 95k exceeds 70% of
// gpt-4o's 128k window, so a send with this state would auto-compact.
(harness.session as unknown as { lastUsageState?: AutoCompactionUsageState }).lastUsageState =
{
lastContextUsage: createDisplayUsage(
{ inputTokens: 95_000, outputTokens: 200, totalTokens: 95_200 },
"openai:gpt-4o"
),
};

const replaceResult = await workspaceService.replaceHistory(
workspaceId,
createMuxMessage("start-here-summary", "assistant", "Start Here summary", {
compacted: "user",
}),
{ mode: "append-compaction-boundary" }
);
expect(replaceResult.success).toBe(true);

const sendResult = await harness.session.sendMessage("follow-up after start here", {
model: "openai:gpt-4o",
agentId: "exec",
});
expect(sendResult.success).toBe(true);

const activeWindow = await historyService.getHistoryFromLatestBoundary(workspaceId);
expect(activeWindow.success).toBe(true);
const activeMessages = activeWindow.success ? activeWindow.data : [];
expect(
activeMessages.filter(
(message) => message.metadata?.muxMetadata?.type === "compaction-request"
)
).toHaveLength(0);
const followUp = activeMessages.find((message) => message.role === "user");
expect(followUp?.parts[0]).toMatchObject({
type: "text",
text: "follow-up after start here",
});
expect(streamMessage).toHaveBeenCalledTimes(1);
} finally {
harness.session.dispose();
await cleanup();
}
});

test("context reset clears stale session usage state", async () => {
const { config, historyService, workspaceService, cleanup } = await createServices();
const workspaceId = "context-reset-clears-usage-state";
try {
await config.addWorkspace("/tmp/context-reset-usage-project", {
id: workspaceId,
name: workspaceId,
projectName: "context-reset-usage-project",
projectPath: "/tmp/context-reset-usage-project",
runtimeConfig: { type: "local" },
});
expect(
(
await historyService.appendToHistory(
workspaceId,
createMuxMessage("pre-reset-user", "user", "before reset", {})
)
).success
).toBe(true);

const clearUsageState = mock(() => undefined);
const session = {
isBusy: mock(() => false),
hasQueuedMessages: mock(() => false),
isPreparingTurn: mock(() => false),
hasPendingAutoRetry: mock(() => false),
emitChatEvent: mock(() => undefined),
clearUsageState,
clearFileState: mock(() => undefined),
} as unknown as AgentSession;
(workspaceService as unknown as { sessions: Map<string, AgentSession> }).sessions.set(
workspaceId,
session
);

expect(await workspaceService.resetContext(workspaceId)).toEqual({
success: true,
data: "reset",
});

expect(clearUsageState).toHaveBeenCalledTimes(1);
} finally {
await cleanup();
}
});

test("full history clear clears stale session usage state", async () => {
const { config, historyService, workspaceService, cleanup } = await createServices();
const workspaceId = "full-clear-clears-usage-state";
try {
await config.addWorkspace("/tmp/full-clear-usage-project", {
id: workspaceId,
name: workspaceId,
projectName: "full-clear-usage-project",
projectPath: "/tmp/full-clear-usage-project",
runtimeConfig: { type: "local" },
});
expect(
(
await historyService.appendToHistory(
workspaceId,
createMuxMessage("pre-clear-user", "user", "before clear", {})
)
).success
).toBe(true);

const clearUsageState = mock(() => undefined);
const session = {
isBusy: mock(() => false),
emitChatEvent: mock(() => undefined),
clearUsageState,
clearFileState: mock(() => undefined),
} as unknown as AgentSession;
(workspaceService as unknown as { sessions: Map<string, AgentSession> }).sessions.set(
workspaceId,
session
);

// A zero-percentage truncation rewrites nothing, so usage must survive.
expect((await workspaceService.truncateHistory(workspaceId, 0)).success).toBe(true);
expect(clearUsageState).toHaveBeenCalledTimes(0);

expect((await workspaceService.truncateHistory(workspaceId, 1.0)).success).toBe(true);
expect(clearUsageState).toHaveBeenCalledTimes(1);
} finally {
await cleanup();
}
});

test("context reset is a no-op when repeated without provider-eligible messages", async () => {
const { config, historyService, workspaceService, cleanup } = await createServices();
const workspaceId = "context-reset-noop";
Expand Down
9 changes: 9 additions & 0 deletions src/node/services/workspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9677,6 +9677,10 @@ export class WorkspaceService extends EventEmitter {
return Err(truncateResult.error);
}

if (effectivePercentage > 0) {
session?.clearUsageState();
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated
}
Comment thread
ibetitsmike marked this conversation as resolved.
Outdated

const deletedSequences = truncateResult.data;
if (deletedSequences.length > 0) {
const deleteMessage: DeleteMessage = {
Expand Down Expand Up @@ -9758,6 +9762,8 @@ export class WorkspaceService extends EventEmitter {
return Err(`Failed to append context reset boundary: ${appendResult.error}`);
}

session?.clearUsageState();

const typedBoundaryMessage = { ...boundaryMessage, type: "message" as const };
if (session) {
session.emitChatEvent(typedBoundaryMessage);
Expand Down Expand Up @@ -9868,6 +9874,8 @@ export class WorkspaceService extends EventEmitter {
if (!clearResult.success) {
return Err(`Failed to clear history: ${clearResult.error}`);
}
// History is already gone even if the summary append below fails.
this.sessions.get(workspaceId)?.clearUsageState();
this.timelineRecorder.record(workspaceId, {
kind: "history.cleared",
source: { system: "chat" },
Expand All @@ -9883,6 +9891,7 @@ export class WorkspaceService extends EventEmitter {

// Emit through the session so ORPC subscriptions receive the events
const session = this.sessions.get(workspaceId);
session?.clearUsageState();
if (deletedSequences.length > 0) {
const deleteMessage: DeleteMessage = {
type: "delete",
Expand Down
22 changes: 22 additions & 0 deletions tests/ui/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ export function installDom(): () => void {
previous.IntersectionObserver;
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver =
previous.ResizeObserver;

// Self-heal: some test files tear down with `globalThis.document = undefined`.
// If that poison leaked into our snapshot, restoring it would propagate a
// document-less environment (with baseline observers still present) to later
// test files, crashing module evals that probe the DOM (e.g. @react-dnd/asap
// calls document.createTextNode when MutationObserver exists). Re-bootstrap a
// baseline instead of re-exposing the poisoned snapshot.
if (typeof globalThis.document === "undefined") {
installDom();
}
};
}

Expand All @@ -243,3 +253,15 @@ export function installDom(): () => void {
if (typeof globalThis.document === "undefined") {
installDom();
}

// Evaluate react-dnd's module graph once while the DOM baseline is healthy.
// bun test runs every file in one process; if a poisoned environment (document
// undefined, MutationObserver present) reaches react-dnd's first evaluation,
// @react-dnd/asap throws at module scope and leaves the package's internal
// exports in TDZ, so every later import (including `?real=1` re-evals) fails
// with "Cannot access 'DragPreviewImage' before initialization". Caching the
// healthy evaluation here makes later re-evals resolve initialized internals.
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("react-dnd");
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("react-dnd-html5-backend");
Loading
Loading