Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions src/browser/features/Memory/MemoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -551,10 +551,16 @@ function ConsolidationFooter(props: {
status?.projectAvailable === false
? "unavailable"
: formatConsolidationRecord(status?.projectRecord ?? null);
const harvestLabel = status?.latestHarvestRecord
? `${status.latestHarvestRecord.status}: ${status.latestHarvestRecord.acceptedCandidates} accepted / ${status.latestHarvestRecord.skippedCandidates} skipped`
: "never";
const harvestError =
status?.latestHarvestRecord?.status === "failed" ? status.latestHarvestRecord.error : undefined;
const summaryTitle = [
status?.workspaceRecord?.summary,
status?.projectRecord?.summary,
status?.globalRecord?.summary,
status?.latestHarvestRecord?.error,
]
.filter(Boolean)
.join("\n");
Expand All @@ -577,6 +583,12 @@ function ConsolidationFooter(props: {
<div className="counter-nums truncate">
Global: {formatConsolidationRecord(status?.globalRecord ?? null)}
</div>
<div className="counter-nums truncate">Harvest: {harvestLabel}</div>
{harvestError !== undefined && (
<div role="alert" className="text-error truncate">
Harvest error: {harvestError}
</div>
)}
{runError !== null && <div className="text-error truncate">{runError}</div>}
</div>
</TooltipIfPresent>
Expand Down
11 changes: 11 additions & 0 deletions src/browser/features/RightSidebar/Memory/MemoryTab.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ function renderTab(width: string) {
workspaceRecord: null,
projectRecord: CONSOLIDATION_RECORD,
globalRecord: CONSOLIDATION_RECORD,
latestHarvestRecord: {
status: "completed",
startedAt: Date.now() - 45 * 60 * 1000,
completedAt: Date.now() - 44 * 60 * 1000,
attemptCount: 1,
boundaryKey: "summary-story",
compactionEpoch: 3,
acceptedCandidates: 2,
skippedCandidates: 1,
},
projectAvailable: true,
},
})}
Expand All @@ -121,6 +131,7 @@ export const List: Story = {
const canvas = within(canvasElement);
await canvas.findByText("preferences.md");
await canvas.findByText(/Project:/);
await canvas.findByText(/Harvest: completed/);
await canvas.findByText("scratch.md");
},
};
Expand Down
29 changes: 29 additions & 0 deletions src/browser/features/RightSidebar/Memory/MemoryTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const DEFAULT_CONSOLIDATION_STATUS: MemoryConsolidationStatusPayload = {
workspaceRecord: null,
projectRecord: null,
globalRecord: null,
latestHarvestRecord: null,
projectAvailable: true,
};

Expand Down Expand Up @@ -277,6 +278,7 @@ describe("MemoryTab", () => {
},
projectRecord: null,
globalRecord: null,
latestHarvestRecord: null,
projectAvailable: true,
},
});
Expand All @@ -290,6 +292,33 @@ describe("MemoryTab", () => {
expect(statusBlock!.getAttribute("title")).toBeNull();
});

test("renders failed harvest errors inline for keyboard and screen reader access", async () => {
fake = createFakeMemoryApi([], {
consolidationStatus: {
workspaceRecord: null,
projectRecord: null,
globalRecord: null,
projectAvailable: true,
latestHarvestRecord: {
status: "failed",
startedAt: Date.now(),
completedAt: Date.now(),
attemptCount: 1,
boundaryKey: "summary-1",
compactionEpoch: 1,
acceptedCandidates: 0,
skippedCandidates: 0,
error: "harvest provider failed",
},
},
});
const { findByRole } = render(<MemoryTab workspaceId="ws-1" />);

expect((await findByRole("alert")).textContent).toContain(
"Harvest error: harvest provider failed"
);
});

test("shows usage stats for used files and omits them for never-used files", async () => {
fake = createFakeMemoryApi([
fileInfo({
Expand Down
2 changes: 2 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
workspaceRecord: defaultConsolidationRecord,
projectRecord: defaultConsolidationRecord,
globalRecord: defaultConsolidationRecord,
latestHarvestRecord: null,
projectAvailable: true,
};
let memoryFilesState: MemoryFileInfo[] = memoryFiles.map((file) => ({ ...file }));
Expand Down Expand Up @@ -1945,6 +1946,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
workspaceRecord: record,
projectRecord: memoryConsolidationStatusState.projectAvailable ? record : null,
globalRecord: record,
latestHarvestRecord: memoryConsolidationStatusState.latestHarvestRecord,
projectAvailable: memoryConsolidationStatusState.projectAvailable,
};
return Promise.resolve({ success: true as const, data: record });
Expand Down
25 changes: 25 additions & 0 deletions src/common/orpc/schemas/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,35 @@ export const MemoryConsolidationRecordSchema = z.object({
});
export type MemoryConsolidationRecordPayload = z.infer<typeof MemoryConsolidationRecordSchema>;

export const CompactionCompletionMetadataSchema = z.object({
workspaceId: z.string(),
summaryMessageId: z.string(),
summaryHistorySequence: z.number(),
compactionEpoch: z.number(),
previousBoundaryHistorySequence: z.number().optional(),
compactionRequestMessageId: z.string(),
});

export const MemoryHarvestRecordSchema = z.object({
status: z.enum(["pending", "completed", "failed"]),
startedAt: z.number(),
completedAt: z.number().optional(),
attemptCount: z.number(),
boundaryKey: z.string(),
compactionEpoch: z.number(),
acceptedCandidates: z.number(),
skippedCandidates: z.number(),
error: z.string().optional(),
usage: z.object({ inputTokens: z.number(), outputTokens: z.number() }).optional(),
completionMetadata: CompactionCompletionMetadataSchema.optional(),
});
export type MemoryHarvestRecordPayload = z.infer<typeof MemoryHarvestRecordSchema>;

export const MemoryConsolidationStatusSchema = z.object({
workspaceRecord: MemoryConsolidationRecordSchema.nullable(),
projectRecord: MemoryConsolidationRecordSchema.nullable(),
globalRecord: MemoryConsolidationRecordSchema.nullable(),
latestHarvestRecord: MemoryHarvestRecordSchema.nullable(),
projectAvailable: z.boolean(),
});
export type MemoryConsolidationStatusPayload = z.infer<typeof MemoryConsolidationStatusSchema>;
Expand Down
8 changes: 8 additions & 0 deletions src/common/types/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface CompactionCompletionMetadata {
workspaceId: string;
summaryMessageId: string;
summaryHistorySequence: number;
compactionEpoch: number;
previousBoundaryHistorySequence?: number;
compactionRequestMessageId: string;
}
101 changes: 101 additions & 0 deletions src/node/services/agentSession.postCompactionRefresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,117 @@ import type { AIService } from "./aiService";
import type { InitStateManager } from "./initStateManager";
import type { BackgroundProcessManager } from "./backgroundProcessManager";
import { createTestHistoryService } from "./testHistoryService";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import { createMuxMessage } from "@/common/types/message";
import type { StreamEndEvent } from "@/common/types/stream";
import { createAgentSessionHarness } from "./agentSession.testHarness";

// NOTE: These tests focus on the event wiring (tool-call-end -> callback).
// The actual post-compaction state computation is covered elsewhere.

async function waitForCondition(assertion: () => void): Promise<void> {
const deadline = Date.now() + 1000;
let lastError: unknown;

while (Date.now() < deadline) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

try {
assertion();
} catch (error) {
if (error instanceof Error) throw error;
throw new Error(String(error));
}

if (lastError instanceof Error) throw lastError;
if (lastError != null) throw new Error("condition failed with non-Error value");
}

describe("AgentSession post-compaction refresh trigger", () => {
let historyCleanup: (() => Promise<void>) | undefined;
afterEach(async () => {
await historyCleanup?.();
});

test("calls compaction-complete callback once for a durable compaction boundary", async () => {
const workspaceId = "ws-compaction-once";
const onCompactionComplete = mock((_metadata: CompactionCompletionMetadata) => undefined);
const { session, historyService, aiEmitter, cleanup } = await createAgentSessionHarness({
workspaceId,
onCompactionComplete,
});
historyCleanup = cleanup;

await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-before-compact", "user", "Remember that we prefer concise tests", {
timestamp: 1000,
})
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("assistant-before-compact", "assistant", "Noted.", {
timestamp: 1001,
})
);
await historyService.appendToHistory(
workspaceId,
createMuxMessage("compact-request", "user", "Please compact", {
timestamp: 1002,
muxMetadata: { type: "compaction-request", rawCommand: "/compact", parsed: {} },
})
);

const streamEnd: StreamEndEvent = {
type: "stream-end",
workspaceId,
messageId: "compact-summary-stream",
parts: [{ type: "text", text: "The user prefers concise tests." }],
metadata: {
model: "openai:gpt-4o",
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
duration: 100,
},
};

aiEmitter.emit("stream-end", streamEnd);

await waitForCondition(() => {
expect(onCompactionComplete).toHaveBeenCalledTimes(1);
});
const completionMetadata = onCompactionComplete.mock.calls[0]?.[0];
expect(completionMetadata).toBeDefined();
if (completionMetadata === undefined) throw new Error("missing compaction completion metadata");
expect(completionMetadata.workspaceId).toBe(workspaceId);
expect(typeof completionMetadata.summaryMessageId).toBe("string");
expect(typeof completionMetadata.summaryHistorySequence).toBe("number");
expect(completionMetadata.compactionEpoch).toBe(1);
expect(completionMetadata.compactionRequestMessageId).toBe("compact-request");

const history = await historyService.getHistoryFromLatestBoundary(workspaceId);
expect(history.success).toBe(true);
if (!history.success) throw new Error(history.error);
expect(history.data).toHaveLength(1);
expect(history.data[0]?.metadata?.compactionBoundary).toBe(true);
expect(history.data[0]?.parts[0]).toMatchObject({
type: "text",
text: "The user prefers concise tests.",
});

aiEmitter.emit("stream-end", streamEnd);
await new Promise((resolve) => setTimeout(resolve, 25));
expect(onCompactionComplete).toHaveBeenCalledTimes(1);

session.dispose();
});

test("triggers callback on file_edit_* tool-call-end", async () => {
const handlers = new Map<string, (...args: unknown[]) => void>();

Expand Down
3 changes: 3 additions & 0 deletions src/node/services/agentSession.testHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Ok } from "@/common/types/result";
import type { Config } from "@/node/config";
import type { AIService } from "@/node/services/aiService";
import { AgentSession } from "@/node/services/agentSession";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager";
import type { HistoryService } from "@/node/services/historyService";
import type { InitStateManager } from "@/node/services/initStateManager";
Expand Down Expand Up @@ -64,6 +65,7 @@ export interface AgentSessionHarnessOptions {
initStateManagerOverrides?: Partial<InitStateManager>;
backgroundProcessManager?: BackgroundProcessManager;
backgroundProcessManagerOverrides?: Partial<BackgroundProcessManager>;
onCompactionComplete?: (metadata: CompactionCompletionMetadata) => void;
captureEvents?: boolean;
}

Expand Down Expand Up @@ -105,6 +107,7 @@ export async function createAgentSessionHarness(
aiService,
initStateManager,
backgroundProcessManager,
onCompactionComplete: options.onCompactionComplete,
});

const events: WorkspaceChatMessage[] = [];
Expand Down
7 changes: 2 additions & 5 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
import type { GoalStreamOriginKind, WorkspaceGoalService } from "./workspaceGoalService";
import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries";
import { getTotalCost } from "@/common/utils/tokens/usageAggregator";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import { CompactionHandler } from "./compactionHandler";
import { RetryManager, type RetryFailureError, type RetryStatusEvent } from "./retryManager";
import type { TelemetryService } from "./telemetryService";
Expand Down Expand Up @@ -315,7 +316,7 @@ interface AgentSessionOptions {
/** When true, skip terminating background processes on dispose/compaction (for bench/CI) */
keepBackgroundProcesses?: boolean;
/** Called when compaction completes (e.g., to clear idle compaction pending state) */
onCompactionComplete?: () => void;
onCompactionComplete?: (metadata: CompactionCompletionMetadata) => void;
/** Called when post-compaction context state may have changed (plan/file edits) */
onPostCompactionStateChange?: () => void;
}
Expand Down Expand Up @@ -343,7 +344,6 @@ export class AgentSession {
private readonly backgroundProcessManager: BackgroundProcessManager;
private readonly workspaceGoalService?: WorkspaceGoalService;
private readonly keepBackgroundProcesses: boolean;
private readonly onCompactionComplete?: () => void;
private readonly onPostCompactionStateChange?: () => void;
private readonly emitter = new EventEmitter();
private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> =
Expand Down Expand Up @@ -530,7 +530,6 @@ export class AgentSession {
this.backgroundProcessManager = backgroundProcessManager;
this.workspaceGoalService = workspaceGoalService;
this.keepBackgroundProcesses = keepBackgroundProcesses ?? false;
this.onCompactionComplete = onCompactionComplete;
this.onPostCompactionStateChange = onPostCompactionStateChange;

this.compactionHandler = new CompactionHandler({
Expand Down Expand Up @@ -4691,8 +4690,6 @@ export class AgentSession {
newUsagePercent: 0,
});
}

this.onCompactionComplete?.();
}

// IMPORTANT: reset BEFORE anything that can start a new stream,
Expand Down
29 changes: 29 additions & 0 deletions src/node/services/compactionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import * as os from "os";
import * as path from "path";

import type { EventEmitter } from "events";
import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary";
import { MAX_EDITED_FILES } from "@/common/constants/attachments";
import { createMuxMessage, type MuxMessage } from "@/common/types/message";
import type { CompactionCompletionMetadata } from "@/common/types/compaction";
import type { StreamEndEvent } from "@/common/types/stream";
import type { TelemetryService } from "./telemetryService";
import type { TelemetryEventPayload } from "@/common/telemetry/payload";
Expand Down Expand Up @@ -207,6 +209,33 @@ describe("CompactionHandler", () => {
expect(result).toBe(false);
});

it("reports the latest durable context boundary sequence in completion metadata", async () => {
const onCompactionComplete = mock((_metadata: CompactionCompletionMetadata) => undefined);
handler = new CompactionHandler({
workspaceId,
historyService,
sessionDir,
telemetryService,
emitter: mockEmitter,
onCompactionComplete,
});
await seedHistory(
createMuxMessage("stale-user", "user", "old preference"),
createMuxMessage("reset", "assistant", "Context reset", {
contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET,
}),
createMuxMessage("fresh-user", "user", "new preference"),
createCompactionRequest("compact-request")
);

const handled = await handler.handleCompletion(createStreamEndEvent("Summary"));

expect(handled).toBe(true);
expect(onCompactionComplete).toHaveBeenCalledTimes(1);
const metadata = onCompactionComplete.mock.calls[0]?.[0];
expect(metadata?.previousBoundaryHistorySequence).toBe(1);
});

it("should capture compaction_completed telemetry on successful compaction", async () => {
const compactionReq = createCompactionRequest();
await seedHistory(compactionReq);
Expand Down
Loading
Loading