fix(agent): reset promoted memory prompt on session fork

fork() reset mnemopi conversation tracking directly but skipped the shared new-transcript reset, so the folded/promoted first-turn memory stayed in #baseSystemPrompt. The next turn re-recalled and the change-detection saw no diff, taking the fallback promotion path and injecting the <memories> block twice into the forked session prompt. Route fork() through #resetMemoryContextForNewTranscript() like the other reset paths and add a regression test asserting the forked prompt contains recalled memory exactly once.
This commit is contained in:
can1357
2026-06-20 21:49:08 +02:00
parent 10ed2d4efa
commit ffc842ee7c
2 changed files with 93 additions and 1 deletions
@@ -6903,7 +6903,7 @@ export class AgentSession {
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
this.#resetMnemopiConversationTrackingIfMnemopi();
await this.#resetMemoryContextForNewTranscript();
// Emit session_switch event with reason "fork" to hooks
if (this.#extensionRunner) {
@@ -782,4 +782,96 @@ describe("AgentSession message pipeline", () => {
expect(contexts).toHaveLength(2);
expect(contexts[1]!.systemPrompt?.join("\n")).not.toContain(injected);
});
it("does not duplicate promoted memory in the base prompt when forking", async () => {
using tempDir = TempDir.createSync("@pi-injected-memory-fork-");
const sessionManager = SessionManager.create(tempDir.path(), tempDir.join("sessions"));
expect(sessionManager.getSessionFile()).toBeString();
await sessionManager.flush();
const api = "test-injected-memory-fork-cache";
const contexts: Context[] = [];
let remembered = false;
const injected = "<memories>forked recall</memories>";
const fakeBackend: MemoryBackend = {
id: "mnemopi",
async start() {},
async buildDeveloperInstructions() {
return remembered ? `static memory instructions\n\n${injected}` : "static memory instructions";
},
async clear() {},
async enqueue() {},
async beforeAgentStartPrompt() {
if (remembered) return undefined;
remembered = true;
return injected;
},
};
vi.spyOn(memoryBackend, "resolveMemoryBackend").mockResolvedValue(fakeBackend);
registerCustomApi(api, (_model, context) => {
contexts.push(context);
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const message = createAssistantMessage("ok");
stream.push({ type: "text_delta", contentIndex: 0, delta: "ok", partial: message });
stream.push({ type: "done", reason: "stop", message });
});
return stream;
});
const model = buildModel({
id: "local-model",
name: "Local Model",
api,
provider: "ollama",
baseUrl: "http://127.0.0.1:11434",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 4096,
maxTokens: 1024,
} as ModelSpec<Api>) as Model<Api>;
const agent = new Agent({
initialState: {
model,
systemPrompt: ["base", "static memory instructions"],
messages: [],
tools: [],
},
});
agent.setAppendOnlyContext(new AppendOnlyContextManager());
const session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({
"compaction.enabled": false,
"memory.backend": "mnemopi",
"provider.appendOnlyContext": "on",
}),
modelRegistry: createModelRegistryStub() as never,
rebuildSystemPrompt: async () => ({
systemPrompt: remembered
? ["base", `static memory instructions\n\n${injected}`]
: ["base", "static memory instructions"],
}),
});
sessions.push(session);
setMnemopiSessionState(session, {
aliasOf: undefined,
setSessionId(_sessionId: string) {},
resetConversationTracking() {
remembered = false;
},
async dispose() {},
} as unknown as MnemopiSessionState);
await session.sendUserMessage("first");
expect(session.systemPrompt.join("\n")).toContain(injected);
await session.fork();
await session.sendUserMessage("second");
const forkedPrompt = contexts[1]!.systemPrompt?.join("\n") ?? "";
const occurrences = forkedPrompt.split(injected).length - 1;
expect(occurrences).toBe(1);
});
});