The remember tool rewrote the bot's whole MEMORY.md with each call, so every
new fact erased the earlier ones unless the model re-sent the full document.
- MemoryCommitRequest gains mode "append" (the store joins the note onto the
current content inside the same serializable transaction and still writes a
revision row) and every commit is capped at 16 KB per document.
- remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date)
by default and rewrites only with mode "replace"; a full document returns an
error that tells the bot to condense it. Retries of the same tool call stay
single appends through the existing effect record.
- Scripted runtime memory entries can append too; the scripted "remember"
script now appends instead of overwriting the bot's memory.
- When memory does not fit the run's 32 KB budget, the prompt keeps a
document's newest lines instead of its oldest.
- memory.update maps an oversized save to BAD_REQUEST.
- docs/PRODUCT.md records that UserFact is the cross-space user model and the
space MEMORY.md stays space notes (no migration).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
550 lines
17 KiB
TypeScript
550 lines
17 KiB
TypeScript
import type { AgentRunRequest } from "@rakazo/adapter-kit";
|
|
import {
|
|
legacyScopedToolEffectIdempotencyKey,
|
|
toolEffectIdempotencyKey,
|
|
} from "@rakazo/core/node/approval-effect-key";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type * as AutoReviewModule from "./auto-review.js";
|
|
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
|
|
import { createRunExecutor } from "./executor.js";
|
|
|
|
vi.mock("./computer-lifecycle.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof ComputerLifecycleModule>()),
|
|
acquireComputerExecutionLease: async () => null,
|
|
provisionComputer: async () => ({ id: "computer-1", kind: "desktop" }),
|
|
}));
|
|
|
|
vi.mock("./auto-review.js", async (importOriginal) => ({
|
|
...(await importOriginal<typeof AutoReviewModule>()),
|
|
resolveAutoReviewChecker: () => ({ provider: "scripted", model: "checker" }),
|
|
isAutoReviewCheckerConfigured: () => true,
|
|
runAutoReviewJudge: vi.fn(),
|
|
}));
|
|
|
|
type Effect = {
|
|
id: string;
|
|
runId?: string;
|
|
kind: string;
|
|
idempotencyKey: string;
|
|
status: string;
|
|
request: unknown;
|
|
result?: unknown;
|
|
reviewDecision?: string;
|
|
};
|
|
|
|
type ToolCall = {
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
executionId: string;
|
|
};
|
|
|
|
function fixture(runId = "run-1") {
|
|
const effects: Effect[] = [];
|
|
const results: unknown[] = [];
|
|
const scratchpadRows: Array<{
|
|
id: string;
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
title: string;
|
|
status: string;
|
|
notes: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}> = [];
|
|
const run = {
|
|
id: runId,
|
|
botId: "bot-1",
|
|
threadId: "thread-1",
|
|
taskId: "task-1",
|
|
spaceId: "space-1",
|
|
userId: "user-1",
|
|
status: "queued",
|
|
trigger: "user",
|
|
leaseFence: 0,
|
|
};
|
|
const memoryCommit = vi.fn(async () => ({ revision: "rev-1" }));
|
|
const externalEffect = {
|
|
findMany: vi.fn(
|
|
async ({
|
|
where,
|
|
}: {
|
|
where?: { id?: string; runId?: string; status?: string; kind?: string };
|
|
} = {}) =>
|
|
effects.filter((effect) => {
|
|
if (where?.status && effect.status !== where.status) return false;
|
|
if (where?.kind && effect.kind !== where.kind) return false;
|
|
if (where?.runId && effect.runId && effect.runId !== where.runId) return false;
|
|
if (where?.id && effect.id !== where.id) return false;
|
|
return true;
|
|
}),
|
|
),
|
|
findUnique: vi.fn(
|
|
async ({ where }: { where: { id?: string; idempotencyKey?: string } }) =>
|
|
effects.find((effect) =>
|
|
where.id ? effect.id === where.id : effect.idempotencyKey === where.idempotencyKey,
|
|
) ?? null,
|
|
),
|
|
create: vi.fn(async ({ data }: { data: Omit<Effect, "id"> }) => {
|
|
const effect = { ...data, id: `effect-${effects.length + 1}` };
|
|
effects.push(effect);
|
|
return { ...effect };
|
|
}),
|
|
update: vi.fn(async ({ where, data }: { where: { id: string }; data: Partial<Effect> }) => {
|
|
Object.assign(effects.find((effect) => effect.id === where.id)!, data);
|
|
}),
|
|
updateMany: vi.fn(
|
|
async ({ where, data }: { where: { id: string; status: string }; data: Partial<Effect> }) => {
|
|
const effect = effects.find(
|
|
(effect) => effect.id === where.id && effect.status === where.status,
|
|
);
|
|
if (!effect) return { count: 0 };
|
|
Object.assign(effect, data);
|
|
return { count: 1 };
|
|
},
|
|
),
|
|
};
|
|
const prisma = {
|
|
run: {
|
|
findUnique: vi.fn(async () => run),
|
|
findUniqueOrThrow: vi.fn(async () => run),
|
|
updateMany: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
|
Object.assign(run, data);
|
|
return { count: 1 };
|
|
}),
|
|
},
|
|
bot: {
|
|
findUniqueOrThrow: vi.fn(async () => ({
|
|
id: run.botId,
|
|
name: "Assistant",
|
|
title: "Assistant",
|
|
description: "Test assistant",
|
|
computerId: "computer-1",
|
|
computer: { id: "computer-1", scope: "dedicated" },
|
|
})),
|
|
findMany: vi.fn(async () => []),
|
|
},
|
|
attempt: {
|
|
create: vi.fn(async () => ({ id: "attempt-1" })),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
|
},
|
|
thread: { findUniqueOrThrow: vi.fn(async () => ({ id: run.threadId, groupId: null })) },
|
|
message: { findMany: vi.fn(async () => []) },
|
|
task: {
|
|
findUniqueOrThrow: vi.fn(async () => ({ id: run.taskId, prompt: "Update shared state" })),
|
|
},
|
|
connection: { findMany: vi.fn(async () => []) },
|
|
spaceModelPreference: { findFirst: vi.fn(async () => null) },
|
|
userModelCredential: { findFirst: vi.fn(async () => null) },
|
|
deploymentSettings: {
|
|
findUnique: vi.fn(async () => ({
|
|
defaultModelProvider: "scripted",
|
|
defaultModelId: "scripted",
|
|
})),
|
|
},
|
|
taughtSkill: { findMany: vi.fn(async () => []) },
|
|
agentSecret: { findMany: vi.fn(async () => []) },
|
|
user: { findUnique: vi.fn(async () => null) },
|
|
userProfile: { findUnique: vi.fn(async () => null) },
|
|
userFact: { findMany: vi.fn(async () => []) },
|
|
agentSkill: { findMany: vi.fn(async () => []) },
|
|
scratchpadItem: {
|
|
findMany: vi.fn(async () => scratchpadRows),
|
|
create: vi.fn(
|
|
async ({
|
|
data,
|
|
}: {
|
|
data: {
|
|
spaceId: string;
|
|
botId: string;
|
|
userId: string;
|
|
title: string;
|
|
status: string;
|
|
notes: string;
|
|
};
|
|
}) => {
|
|
const now = new Date("2026-09-14T00:00:00.000Z");
|
|
const row = {
|
|
id: `scratch-${scratchpadRows.length + 1}`,
|
|
...data,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
scratchpadRows.push(row);
|
|
return row;
|
|
},
|
|
),
|
|
},
|
|
actionApprovalRule: { findMany: vi.fn(async () => []) },
|
|
actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) },
|
|
externalEffect,
|
|
};
|
|
const pauseRunForInput = vi.fn(async () => {
|
|
run.status = "waiting_input";
|
|
return true;
|
|
});
|
|
const finalizeRun = vi.fn(async () => ({ continuationRunId: null }));
|
|
let calls: ToolCall[] = [];
|
|
const runtimeRun = vi.fn(async function* (request: AgentRunRequest) {
|
|
for (const call of calls) {
|
|
const result = await request.executeTool!(call.name, call.args, call.executionId);
|
|
results.push(result);
|
|
}
|
|
yield { type: "done" as const, text: "Done" };
|
|
});
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
runtime: { describe: () => ({ capabilities: { scripted: false } }), run: runtimeRun },
|
|
connector: {
|
|
discoverTools: async () => [],
|
|
resolveCall: async () => undefined,
|
|
execute: async function* () {},
|
|
},
|
|
sandbox: { describe: () => ({ capabilities: { graphical: false } }) },
|
|
memory: {
|
|
describe: () => ({ capabilities: {} }),
|
|
read: async () => ({ documents: [] }),
|
|
search: async () => [],
|
|
commit: memoryCommit,
|
|
exportMarkdown: async function* () {},
|
|
},
|
|
memoryProviders: { resolve: async () => null },
|
|
events: { append: vi.fn(async () => undefined), pauseRunForInput, finalizeRun },
|
|
jobs: { enqueue: vi.fn(async () => undefined) },
|
|
secrets: [],
|
|
} as unknown as Parameters<typeof createRunExecutor>[0]);
|
|
|
|
return {
|
|
effects,
|
|
results,
|
|
scratchpadRows,
|
|
memoryCommit,
|
|
setCalls(next: ToolCall[]) {
|
|
calls = next;
|
|
},
|
|
async run() {
|
|
run.status = "queued";
|
|
await executor.continueRun(run.id, "worker-1");
|
|
expect(runtimeRun).toHaveBeenCalled();
|
|
expect(prisma.attempt.update).not.toHaveBeenCalled();
|
|
expect(finalizeRun).not.toHaveBeenCalledWith(expect.objectContaining({ outcome: "failed" }));
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("mutating tool effect idempotency keys", () => {
|
|
it("executes different tools that share a reused provider tool-call id", async () => {
|
|
const f = fixture("run-a");
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "team preference" },
|
|
executionId: "call_0",
|
|
},
|
|
{
|
|
name: "scratchpad_add",
|
|
args: { title: "follow up with design" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(f.scratchpadRows).toHaveLength(1);
|
|
expect(f.effects.map((effect) => effect.idempotencyKey)).toEqual([
|
|
toolEffectIdempotencyKey("run-a", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "team preference",
|
|
}),
|
|
toolEffectIdempotencyKey("run-a", "scratchpad_add", {
|
|
title: "follow up with design",
|
|
}),
|
|
]);
|
|
expect(f.effects.every((effect) => effect.status === "completed")).toBe(true);
|
|
expect(f.results[0]).toEqual({ ok: true });
|
|
expect(f.results[1]).toEqual(
|
|
expect.objectContaining({
|
|
item: expect.objectContaining({ title: "follow up with design" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("executes the same provider tool-call id on different runs", async () => {
|
|
const first = fixture("run-1");
|
|
first.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "first bot note" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
await first.run();
|
|
|
|
const second = fixture("run-2");
|
|
// Shared ExternalEffect table: prior completed rows remain visible by idempotency key.
|
|
second.effects.push(...first.effects);
|
|
second.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "second bot note" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
await second.run();
|
|
|
|
expect(second.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(second.effects.map((effect) => effect.idempotencyKey)).toEqual([
|
|
toolEffectIdempotencyKey("run-1", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "first bot note",
|
|
}),
|
|
toolEffectIdempotencyKey("run-2", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "second bot note",
|
|
}),
|
|
]);
|
|
expect(second.results[0]).toEqual({ ok: true });
|
|
});
|
|
|
|
it("replays a true retry of the same effect without re-executing", async () => {
|
|
const f = fixture("run-retry");
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "durable fact" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
await f.run();
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
// remember appends, so only the effect record keeps a retry from adding the note twice.
|
|
expect(f.memoryCommit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mode: "append",
|
|
content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
expect(f.effects).toHaveLength(1);
|
|
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "durable fact" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(f.effects).toHaveLength(1);
|
|
expect(f.effects[0]?.idempotencyKey).toBe(
|
|
toolEffectIdempotencyKey("run-retry", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "durable fact",
|
|
}),
|
|
);
|
|
expect(f.results[1]).toEqual({ ok: true });
|
|
});
|
|
|
|
it("replays the same logical effect when restart assigns a new tool-call id", async () => {
|
|
const f = fixture("run-replay-id");
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "durable fact" },
|
|
executionId: "toolu_abc",
|
|
},
|
|
]);
|
|
await f.run();
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(f.effects).toHaveLength(1);
|
|
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "durable fact" },
|
|
executionId: "toolu_xyz",
|
|
},
|
|
]);
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(f.effects).toHaveLength(1);
|
|
expect(f.effects[0]?.idempotencyKey).toBe(
|
|
toolEffectIdempotencyKey("run-replay-id", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "durable fact",
|
|
}),
|
|
);
|
|
expect(f.results[1]).toEqual({ ok: true });
|
|
});
|
|
|
|
it("executes the same tool twice in one run when args differ but the provider id is reused", async () => {
|
|
const f = fixture("run-args");
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "first fact" },
|
|
executionId: "call_0",
|
|
},
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "second fact" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledTimes(2);
|
|
expect(f.effects).toHaveLength(2);
|
|
expect(f.effects[0]?.idempotencyKey).not.toBe(f.effects[1]?.idempotencyKey);
|
|
expect(f.results).toEqual([{ ok: true }, { ok: true }]);
|
|
});
|
|
|
|
it("replays a legacy bare provider idempotency key for the same run and tool", async () => {
|
|
const f = fixture("run-legacy");
|
|
f.effects.push({
|
|
id: "legacy-1",
|
|
runId: "run-legacy",
|
|
kind: "remember",
|
|
idempotencyKey: "call_0",
|
|
status: "completed",
|
|
request: { path: "MEMORY.md", content: "legacy fact" },
|
|
result: { ok: true, legacy: true },
|
|
});
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "legacy fact" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).not.toHaveBeenCalled();
|
|
expect(f.effects).toHaveLength(1);
|
|
expect(f.results[0]).toEqual({ ok: true, legacy: true });
|
|
});
|
|
|
|
it("replays a legacy scoped key that included the provider tool-call id", async () => {
|
|
const args = { path: "MEMORY.md", content: "legacy scoped fact" };
|
|
const f = fixture("run-legacy-scoped");
|
|
f.effects.push({
|
|
id: "legacy-scoped-1",
|
|
runId: "run-legacy-scoped",
|
|
kind: "remember",
|
|
idempotencyKey: legacyScopedToolEffectIdempotencyKey(
|
|
"run-legacy-scoped",
|
|
"remember",
|
|
"call_0",
|
|
args,
|
|
),
|
|
status: "completed",
|
|
request: args,
|
|
result: { ok: true, legacy: true },
|
|
});
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args,
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).not.toHaveBeenCalled();
|
|
expect(f.effects).toHaveLength(1);
|
|
expect(f.results[0]).toEqual({ ok: true, legacy: true });
|
|
});
|
|
|
|
it("does not reuse an incomplete legacy effect when the request differs", async () => {
|
|
const f = fixture("run-legacy-mismatch");
|
|
f.effects.push({
|
|
id: "legacy-open",
|
|
runId: "run-legacy-mismatch",
|
|
kind: "remember",
|
|
idempotencyKey: "call_0",
|
|
status: "intended",
|
|
request: { path: "MEMORY.md", content: "old fact" },
|
|
});
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args: { path: "MEMORY.md", content: "new fact" },
|
|
executionId: "call_0",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
|
expect(f.effects).toHaveLength(2);
|
|
expect(f.effects[1]?.idempotencyKey).toBe(
|
|
toolEffectIdempotencyKey("run-legacy-mismatch", "remember", {
|
|
path: "MEMORY.md",
|
|
content: "new fact",
|
|
}),
|
|
);
|
|
expect(f.results[0]).toEqual({ ok: true });
|
|
});
|
|
|
|
it("executes two identical-args mutating calls in one live run", async () => {
|
|
const args = { path: "MEMORY.md", content: "same fact" };
|
|
const f = fixture("run-live-repeat");
|
|
f.setCalls([
|
|
{ name: "remember", args, executionId: "call_a" },
|
|
{ name: "remember", args, executionId: "call_b" },
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).toHaveBeenCalledTimes(2);
|
|
expect(f.effects).toHaveLength(2);
|
|
expect(f.effects[0]?.idempotencyKey).toBe(
|
|
toolEffectIdempotencyKey("run-live-repeat", "remember", args),
|
|
);
|
|
expect(f.effects[1]?.idempotencyKey).toBe(
|
|
toolEffectIdempotencyKey("run-live-repeat", "remember", args, 1),
|
|
);
|
|
expect(f.results).toEqual([{ ok: true }, { ok: true }]);
|
|
});
|
|
|
|
it("replays a legacy scoped key when restart assigns a new tool-call id", async () => {
|
|
const args = { path: "MEMORY.md", content: "legacy scoped fact" };
|
|
const f = fixture("run-legacy-new-id");
|
|
f.effects.push({
|
|
id: "legacy-scoped-old-id",
|
|
runId: "run-legacy-new-id",
|
|
kind: "remember",
|
|
idempotencyKey: legacyScopedToolEffectIdempotencyKey(
|
|
"run-legacy-new-id",
|
|
"remember",
|
|
"call_old",
|
|
args,
|
|
),
|
|
status: "completed",
|
|
request: args,
|
|
result: { ok: true, legacy: true },
|
|
});
|
|
f.setCalls([
|
|
{
|
|
name: "remember",
|
|
args,
|
|
executionId: "call_new",
|
|
},
|
|
]);
|
|
|
|
await f.run();
|
|
|
|
expect(f.memoryCommit).not.toHaveBeenCalled();
|
|
expect(f.effects).toHaveLength(1);
|
|
expect(f.results[0]).toEqual({ ok: true, legacy: true });
|
|
});
|
|
});
|