Skip to content
Open
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
19 changes: 12 additions & 7 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3879,13 +3879,18 @@ export const router = (authToken?: string) => {
error: "Project memory is unavailable: no project is associated with this session",
};
}
await context.memoryMetaService.setPinned(
memoryLogicalKey(scope, relPath, {
projectPath: resolved.projectPath,
workspaceId: input.workspaceId ?? "",
}),
input.pinned
);
// Route through MemoryService so the pin change emits a change
// event and invalidates session-cached memory contexts.
try {
await context.memoryService.setPinned(
resolved.scopeCtx,
input.path,
input.pinned,
"user"
);
} catch (error) {
return { success: false as const, error: getErrorMessage(error) };
}
return { success: true as const, data: undefined };
}),
consolidationStatus: t
Expand Down
111 changes: 111 additions & 0 deletions src/node/services/agentSession.memoryContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,117 @@ describe("AgentSession memory context", () => {
}
});

test("recomputes the context after invalidateMemoryContext (memory change / context reset)", async () => {
using sessionDir = new DisposableTempDir("agent-session-memory-context-invalidate");
const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;

let indexEntries = [{ path: "/memories/global/deleted.md", description: "stale" }];
const buildMemorySessionContext = mock(() =>
Promise.resolve({ indexEntries, hotMemoriesBlock: null })
);
const session = createSession({
historyService,
sessionDir: sessionDir.path,
buildMemorySessionContext,
});
const priv = session as unknown as PrivateSessionAccess;

try {
expect((await priv.resolveMemoryContext("test-model"))?.indexEntries).toHaveLength(1);

indexEntries = [];
session.invalidateMemoryContext();

expect((await priv.resolveMemoryContext("test-model"))?.indexEntries).toHaveLength(0);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(2);
} finally {
session.dispose();
}
});

test("rebuilds mid-request when invalidation arrives during an in-flight build", async () => {
using sessionDir = new DisposableTempDir("agent-session-memory-context-race");
const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;

let indexEntries = [{ path: "/memories/global/deleted.md", description: "stale" }];
const pendingReleases: Array<() => void> = [];
const buildMemorySessionContext = mock(async () => {
const entries = indexEntries;
await new Promise<void>((resolve) => {
pendingReleases.push(resolve);
});
return { indexEntries: entries, hotMemoriesBlock: null };
});
const session = createSession({
historyService,
sessionDir: sessionDir.path,
buildMemorySessionContext,
});
const priv = session as unknown as PrivateSessionAccess;

try {
const firstResolve = priv.resolveMemoryContext("test-model");
// The memory change lands while the first build is still awaited.
indexEntries = [];
session.invalidateMemoryContext();
pendingReleases.shift()?.();
// The stale build result triggers a rebuild; release it once it starts.
while (pendingReleases.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
pendingReleases.shift()?.();

// The current request already sees the post-invalidation state.
expect((await firstResolve)?.indexEntries).toHaveLength(0);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(2);

// The clean rebuild was cached: no further build on the next resolve.
const secondResolve = priv.resolveMemoryContext("test-model");
expect((await secondResolve)?.indexEntries).toHaveLength(0);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(2);
} finally {
session.dispose();
}
});

test("persistent invalidation churn stops rebuilding at the attempt bound and stays uncached", async () => {
using sessionDir = new DisposableTempDir("agent-session-memory-context-churn");
const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;

let version = 0;
// Every build observes a concurrent invalidation before it completes.
const buildMemorySessionContext = mock(() => {
version++;
session.invalidateMemoryContext();
return Promise.resolve({
indexEntries: [{ path: `/memories/global/v${version}.md`, description: `v${version}` }],
hotMemoriesBlock: null,
});
});
const session = createSession({
historyService,
sessionDir: sessionDir.path,
buildMemorySessionContext,
});
const priv = session as unknown as PrivateSessionAccess;

try {
// Three attempts (the bound), then the freshest snapshot is served.
const first = await priv.resolveMemoryContext("test-model");
expect(first?.indexEntries[0]?.path).toBe("/memories/global/v3.md");
expect(buildMemorySessionContext).toHaveBeenCalledTimes(3);

// Nothing was cached, so the next request rebuilds.
await priv.resolveMemoryContext("test-model");
expect(buildMemorySessionContext).toHaveBeenCalledTimes(6);
} finally {
session.dispose();
}
});

test("recomputes the context after a compaction boundary is consumed", async () => {
using sessionDir = new DisposableTempDir("agent-session-memory-context-compaction");
const { historyService, cleanup } = await createTestHistoryService();
Expand Down
67 changes: 66 additions & 1 deletion src/node/services/agentSession.startupAutoRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace";
import { Ok } from "@/common/types/result";
import { GOAL_CONTINUATION_KIND } from "@/constants/goals";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary";

interface AutoRetryResumeRequest {
options: SendMessageOptions;
Expand All @@ -33,7 +34,10 @@ interface SessionBundle {
cleanup: () => Promise<void>;
}

async function createSessionBundle(workspaceId: string): Promise<SessionBundle> {
async function createSessionBundle(
workspaceId: string,
getContextResetState?: () => { resetting: boolean; boundaryEpoch: number }
): Promise<SessionBundle> {
const workspaceMetadata: WorkspaceMetadata = {
id: workspaceId,
name: workspaceId,
Expand All @@ -56,6 +60,7 @@ async function createSessionBundle(workspaceId: string): Promise<SessionBundle>
initStateManagerOverrides: {
replayInit: mock(() => Promise.resolve()),
},
getContextResetState,
captureEvents: true,
});
}
Expand Down Expand Up @@ -124,6 +129,66 @@ describe("AgentSession startup auto-retry recovery", () => {
session.dispose();
});

test("defers the retry when a context reset boundary lands after the history reads", async () => {
const workspaceId = "startup-retry-reset-overlap";
let boundaryEpoch = 0;
const { session, historyService, events, cleanup } = await createSessionBundle(
workspaceId,
() => ({ resetting: false, boundaryEpoch })
);
cleanups.push(cleanup);

const appendResult = await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-1", "user", "interrupted turn", { timestamp: Date.now() })
);
expect(appendResult.success).toBe(true);

// Simulate resetContext committing its boundary between this check's
// history reads and its retry decision: the reads return the pre-reset
// tail, then the boundary lands and the epoch advances. Scheduling the
// retry from that stale tail would resume the context the user reset.
const realGetLastMessages = historyService.getLastMessages.bind(historyService);
let overlapped = false;
spyOn(historyService, "getLastMessages").mockImplementation(async (readWorkspaceId, count) => {
const result = await realGetLastMessages(readWorkspaceId, count);
if (!overlapped) {
overlapped = true;
const boundaryAppend = await historyService.appendToHistory(
readWorkspaceId,
createMuxMessage("reset-boundary", "assistant", "", {
timestamp: Date.now(),
contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET,
})
);
expect(boundaryAppend.success).toBe(true);
boundaryEpoch += 1;
}
return result;
});

session.ensureStartupAutoRetryCheck();

// The deferred check re-runs when idle and settles on the post-boundary
// history; wait for the pipeline to finish before asserting.
const priv = session as unknown as {
startupAutoRetryCheckScheduled: boolean;
startupAutoRetryCheckPromise: Promise<void> | null;
};
const deadline = Date.now() + 5000;
while (!(priv.startupAutoRetryCheckScheduled && priv.startupAutoRetryCheckPromise === null)) {
if (Date.now() > deadline) {
throw new Error("Startup auto-retry check did not settle");
}
await new Promise((resolve) => setTimeout(resolve, 5));
}

expect(events.find((event) => event.type === "auto-retry-scheduled")).toBeUndefined();
expect(session.hasPendingAutoRetry()).toBe(false);

session.dispose();
});

test("startup auto-retry reuses workspace-turn metadata from the retry user message", async () => {
const workspaceId = "startup-retry-workspace-turn-metadata";
const { session, historyService, aiService, cleanup } = await createSessionBundle(workspaceId);
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/agentSession.testHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface AgentSessionHarnessOptions {
backgroundProcessManager?: BackgroundProcessManager;
backgroundProcessManagerOverrides?: Partial<BackgroundProcessManager>;
onCompactionComplete?: (metadata: CompactionCompletionMetadata) => void;
getContextResetState?: () => { resetting: boolean; boundaryEpoch: number };
captureEvents?: boolean;
}

Expand Down Expand Up @@ -108,6 +109,7 @@ export async function createAgentSessionHarness(
initStateManager,
backgroundProcessManager,
onCompactionComplete: options.onCompactionComplete,
getContextResetState: options.getContextResetState,
});

const events: WorkspaceChatMessage[] = [];
Expand Down
Loading
Loading