diff --git a/README.md b/README.md index fa00fc5..d2e240d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ The variables set the default: omit both namespace and profile to use `default/a Writes keep the host's session ID wherever they go, and the model can't pass `sessionId` as an argument. A default should be a starting point, not a fence. +Give `memory_remember` up to four `copies` and one call stores the same content and session in each profile. Pasting the same finding twice can finally retire. The main record lands first, but the writes aren't atomic. If a copy fails, the error shows what landed and which profiles still need a retry. + Don't have an API token? The tools can also use `cf` CLI authentication. Run `cf auth login` and omit `CLOUDFLARE_API_TOKEN`. Keep the account ID when the login reaches more than one account. `CLOUDFLARE_ACCOUNT_ID` counts too, so the one your shell already exports for wrangler just works. And when Cloudflare refuses a cf token mid-session, the next call goes back to cf for a fresh one instead of sulking until it expires. diff --git a/extensions/shared/memory-tool-contract.ts b/extensions/shared/memory-tool-contract.ts index 33b4787..2cf9ca4 100644 --- a/extensions/shared/memory-tool-contract.ts +++ b/extensions/shared/memory-tool-contract.ts @@ -39,6 +39,8 @@ export const RECALL_ANSWER_TRUST_GUIDANCE = export const MAX_TOOL_MESSAGE_CHARACTERS = 32_768; export const MAX_TOOL_MEMORY_CONTENT_CHARACTERS = 32_768; export const MAX_TOOL_INGEST_MESSAGES = 500; +/** Extra profiles one memory_remember call may copy into; each copy is one more write. */ +export const MAX_TOOL_MEMORY_COPIES = 4; /** * Cap on how many memories one call may read with full content. * @@ -104,7 +106,7 @@ export const memoryToolMeta = { }, memory_remember: { label: "Remember", - description: `Store one durable memory in a Cloudflare Agent Memory profile. The service writes the content verbatim and assigns the memory type itself. A fact or instruction on the topic of an existing one supersedes it: the older memory leaves memory_list and memory_recall as soon as the newer one is stored, while memory_get still reads it by id, and remembering the same content twice creates a second record that supersedes the first. Events accumulate instead. ${MEMORY_SUMMARY_TRUST_GUIDANCE}`, + description: `Store one durable memory in a Cloudflare Agent Memory profile. The service writes the content verbatim and assigns the memory type itself. A fact or instruction on the topic of an existing one supersedes it: the older memory leaves memory_list and memory_recall as soon as the newer one is stored, while memory_get still reads it by id, and remembering the same content twice creates a second record that supersedes the first. Events accumulate instead. With copies, the same content and session land in each listed profile as well, in one call instead of one call per profile: the first profile is written first, the copies only after it is stored, and each record keeps its own id. ${MEMORY_SUMMARY_TRUST_GUIDANCE}`, }, memory_supersede: { label: "Supersede Memory", @@ -180,6 +182,9 @@ export const memoryFieldDescriptions = { cursor: "Cursor copied from the previous response. A response without a cursor was the last page.", rememberContent: `Memory content to store. The service writes it verbatim, assigns the memory type itself, and rejects content over ${MAX_TOOL_MEMORY_CONTENT_CHARACTERS} UTF-8 bytes, the same cap as one ingested message.`, + rememberCopies: `Up to ${MAX_TOOL_MEMORY_COPIES} more namespace and profile pairs that get the same content and session, in this namespace or any other, for example a project profile next to a shared one. Writes are not atomic: a failed copy names the records already stored and each profile still missing, so retry only those. Each pair must differ from the others and from the main profile.`, + copyNamespace: "Namespace of the copy.", + copyProfile: "Profile inside the copy's namespace. The first write creates it.", messageRole: "Who produced the message.", messageContent: "Message text.", messageTimestamp: diff --git a/extensions/shared/memory-tool-schemas.ts b/extensions/shared/memory-tool-schemas.ts index f67e9a2..8164b23 100644 --- a/extensions/shared/memory-tool-schemas.ts +++ b/extensions/shared/memory-tool-schemas.ts @@ -4,6 +4,7 @@ import { MAX_TOOL_INGEST_MESSAGES, MAX_TOOL_LIST_LIMIT, MAX_TOOL_MEMORY_CONTENT_CHARACTERS, + MAX_TOOL_MEMORY_COPIES, MAX_TOOL_MESSAGE_CHARACTERS, MAX_TOOL_NAMESPACE_CHARACTERS, MAX_TOOL_PROFILE_CHARACTERS, @@ -20,20 +21,38 @@ import type { ProfileParams } from "./memory-tools.types.ts"; /** Parameter schemas of the memory tools. OMP rewrites a bare `typebox` import, so none here. */ /** `defineTool` refuses open objects: a misspelled key would change the call silently. */ const closed = { additionalProperties: false } as const; -const namespaceName = Type.String({ - description: memoryFieldDescriptions.namespace, - minLength: 1, - maxLength: MAX_TOOL_NAMESPACE_CHARACTERS, - pattern: MEMORY_NAME_PATTERN, -}); -const profileParameters = { - namespace: namespaceName, - profile: Type.String({ - description: memoryFieldDescriptions.profile, +/** + * One name rule for every namespace field, whatever its description says. + * + * @param description - What the field means where it sits. + * @returns {TString} The namespace schema. + */ +function namespaceString(description: string) { + return Type.String({ + description, + minLength: 1, + maxLength: MAX_TOOL_NAMESPACE_CHARACTERS, + pattern: MEMORY_NAME_PATTERN, + }); +} +/** + * One name rule for every profile field, whatever its description says. + * + * @param description - What the field means where it sits. + * @returns {TString} The profile schema. + */ +function profileString(description: string) { + return Type.String({ + description, minLength: 1, maxLength: MAX_TOOL_PROFILE_CHARACTERS, pattern: MEMORY_NAME_PATTERN, - }), + }); +} +const namespaceName = namespaceString(memoryFieldDescriptions.namespace); +const profileParameters = { + namespace: namespaceName, + profile: profileString(memoryFieldDescriptions.profile), }; const memoryId = Type.String({ description: memoryFieldDescriptions.memoryId, @@ -107,6 +126,22 @@ export const rememberParameters = Type.Object( maxLength: MAX_TOOL_SESSION_ID_CHARACTERS, }), ), + copies: Type.Optional( + Type.Array( + Type.Object( + { + namespace: namespaceString(memoryFieldDescriptions.copyNamespace), + profile: profileString(memoryFieldDescriptions.copyProfile), + }, + closed, + ), + { + description: memoryFieldDescriptions.rememberCopies, + minItems: 1, + maxItems: MAX_TOOL_MEMORY_COPIES, + }, + ), + ), }, closed, ); @@ -316,22 +351,8 @@ export const profileOnlyParameters = Type.Object(profileParameters, closed); */ function boundProfileParameters(target: string) { return { - namespace: Type.Optional( - Type.String({ - description: memoryFieldDescriptions.boundNamespace(target), - minLength: 1, - maxLength: MAX_TOOL_NAMESPACE_CHARACTERS, - pattern: MEMORY_NAME_PATTERN, - }), - ), - profile: Type.Optional( - Type.String({ - description: memoryFieldDescriptions.boundProfile(target), - minLength: 1, - maxLength: MAX_TOOL_PROFILE_CHARACTERS, - pattern: MEMORY_NAME_PATTERN, - }), - ), + namespace: Type.Optional(namespaceString(memoryFieldDescriptions.boundNamespace(target))), + profile: Type.Optional(profileString(memoryFieldDescriptions.boundProfile(target))), }; } @@ -401,12 +422,17 @@ function rebuildOperationalSchemas( } /** - * The schemas of a surface locked to one profile: no scope fields, and no `sessionId` on a write. + * The schemas of a surface locked to one profile: no scope fields, no `sessionId` on a write, + * and no `copies`, which would carry a write out of that profile. * * @returns {Record} The schemas the model sees. */ export function createLockedMemoryToolSchemas(): Record { - return rebuildOperationalSchemas(scopelessSchema); + return rebuildOperationalSchemas((name) => + name === "rememberParameters" + ? Type.Omit(scopelessSchema(name), ["copies"], closed) + : scopelessSchema(name), + ); } /** diff --git a/extensions/shared/memory-tools.ts b/extensions/shared/memory-tools.ts index 859a46f..94447e9 100644 --- a/extensions/shared/memory-tools.ts +++ b/extensions/shared/memory-tools.ts @@ -12,6 +12,7 @@ import type { import type { CredentialSource } from "@agntn/credentials"; import { type CloudflareCredentials, getCloudflareCredentials } from "./cloudflare-credentials.ts"; import { isIngestedMessageId } from "./memory-ids.ts"; +import { validateMemoryScope } from "./memory-scope.ts"; import { singleLineToolText, TOOL_TEXT_CONTROL_PATTERN } from "./terminal-text.ts"; import { MAX_TOOL_ERROR_CHAIN_DEPTH, toolErrorMessage } from "./tool-errors.ts"; import { @@ -22,6 +23,7 @@ import { MAX_TOOL_RECALL_VISIBILITY_TIMEOUT_SECONDS, SERVICE_GENERATED_UNTRUSTED, MAX_TOOL_EXPAND, + MAX_TOOL_MEMORY_COPIES, DEFAULT_TOOL_RECALL_CANDIDATES, RECALL_RELEVANCE_FLOOR, RECALL_RELEVANCE_FLOOR_TEXT, @@ -952,13 +954,121 @@ function emptyCascade(removed: RemovedCount): boolean { return removed?.count === 0 && !removed.atLeast; } +/** + * Check the copies before the first write, since the schema guards only tool callers. + * + * @param params - The remember call, main profile included. + * @param copies - The extra profiles it names. + * @throws {Error} On a bad count, a bad name, or a profile named twice. + */ +function assertCopies(params: Readonly, copies: readonly ProfileParams[]): void { + const count = Array.isArray(copies as unknown) ? copies.length : 0; + if (count < 1 || count > MAX_TOOL_MEMORY_COPIES) { + throw new Error(`Copies must be a list of 1 to ${MAX_TOOL_MEMORY_COPIES} profiles`); + } + const targets = new Set([memoryTarget(params)]); + for (const copy of copies) { + const target = memoryTarget(validateMemoryScope(copy.namespace, copy.profile, "Each copy")); + if (targets.has(target)) { + throw new Error(`Copies name ${quoteToolText(target)} twice; each profile gets one record`); + } + targets.add(target); + } +} + +interface StoredCopy { + readonly target: string; + readonly memory: Memory; +} + +/** + * One line per stored record, the shared content only under the first. + * + * @param stored - Every record the call wrote, main profile first. + * @returns {string} The result text. + */ +function storedCopiesText(stored: readonly StoredCopy[]): string { + const shared = stored[0]?.memory.content; + const records = stored.map(({ target, memory }, index) => { + if (index === 0 || memory.content !== shared) return { target, ...memory }; + const { content: _content, ...rest } = memory; + return { target, ...rest }; + }); + return `Stored the same memory in ${stored.length} profiles. A copy whose content matches the first record leaves it out.\n${recordLines(records)}`; +} + +/** + * Name what landed and what did not, so a retry goes only to the missing profiles. + * + * @param stored - Records written before or beside the failed copies. + * @param failed - Each failed copy with its reason. + * @returns {Error} One error for the whole call. + */ +function failedCopiesError( + stored: readonly StoredCopy[], + failed: readonly { readonly target: string; readonly reason: unknown }[], +): Error { + const storedText = stored + .map(({ target, memory }) => `${target} ${quoteToolText(memory.id)}`) + .join(", "); + const failedText = failed + .map( + ({ target, reason }) => `${target}: ${withCredentialStep(toolErrorMessage(reason), reason)}`, + ) + .join("; "); + return new Error( + `Stored memory in ${storedText}, but ${failed.length} of ${stored.length + failed.length - 1} copies failed. ${failedText}. Check memory_list in each failed profile before retrying, since a failed write may have landed anyway, and retry only the profiles that lack the memory.`, + ); +} + +/** + * Write the same memory into every copy at once, after the main record is stored. + * + * @param main - The record already in the main profile. + * @param copies - Profiles that get the same content. + * @param write - Content and session of the main record. + * @param options - Abort signal shared by every write. + * @returns {Promise} One line per record, or an error naming the gaps. + */ +async function rememberCopies( + main: StoredCopy, + copies: readonly ProfileParams[], + write: Readonly<{ content: string; sessionId?: string }>, + options: AbortOptions, +): Promise { + const settled = await Promise.allSettled( + copies.map(async (copy) => + requireCapability(await getProfile(copy), "remember")(write, options), + ), + ); + const stored: StoredCopy[] = [main]; + const failed: { target: string; reason: unknown }[] = []; + settled.forEach((outcome, index) => { + const target = memoryTarget(copies[index] as ProfileParams); + if (outcome.status === "fulfilled") stored.push({ target, memory: outcome.value }); + else failed.push({ target, reason: outcome.reason }); + }); + if (failed.length > 0) throw failedCopiesError(stored, failed); + return { + content: [{ type: "text", text: storedCopiesText(stored) }], + details: { records: stored.map(({ target, memory }) => ({ target, ...memory })) }, + }; +} + export const memoryToolExecutors = { async remember(params: RememberParams, options: AbortOptions = {}): Promise { + if (params.copies !== undefined) assertCopies(params, params.copies); + const write = { content: params.content, sessionId: optionalString(params.sessionId) }; const profile = await getProfile(params); - const memory = await requireCapability(profile, "remember")( - { content: params.content, sessionId: optionalString(params.sessionId) }, - options, - ); + const memory = await requireCapability(profile, "remember")(write, options); + if (params.copies !== undefined) { + return rememberCopies( + { target: memoryTarget(params), memory }, + params.copies, + write, + options, + ); + } return { content: [{ type: "text", text: json(withSummaryTrust(memory)) }], details: memory as unknown as Record, diff --git a/extensions/shared/memory-tools.types.ts b/extensions/shared/memory-tools.types.ts index 26cf579..d5d24da 100644 --- a/extensions/shared/memory-tools.types.ts +++ b/extensions/shared/memory-tools.types.ts @@ -61,6 +61,8 @@ export interface ProfileListParams extends NamespaceParams { export interface RememberParams extends ProfileParams { content: string; sessionId?: string; + /** More profiles that get the same content and session, written after the main one. */ + copies?: readonly ProfileParams[]; } export interface SupersedeParams extends ProfileParams { diff --git a/extensions/shared/tui.ts b/extensions/shared/tui.ts index 4c24a76..72c202f 100644 --- a/extensions/shared/tui.ts +++ b/extensions/shared/tui.ts @@ -286,6 +286,7 @@ function callMeta( const meta: string[] = []; if (subject !== undefined && target !== undefined) meta.push(field(target, META_WIDTH)); if (Array.isArray(record.messages)) meta.push(`messages ${record.messages.length}`); + if (Array.isArray(record.copies)) meta.push(`copies ${record.copies.length}`); for (const [key, metaLabel] of Object.entries(CALL_META_LABELS)) { const value = scalarText(record[key]); if (value !== undefined) meta.push(`${metaLabel} ${field(value, META_WIDTH)}`); diff --git a/test/ai.test.ts b/test/ai.test.ts index 4f21fec..cf59612 100644 --- a/test/ai.test.ts +++ b/test/ai.test.ts @@ -262,6 +262,21 @@ describe("@agntn/memory/ai locked to one profile", () => { for (const tool of writes) { expect(Object.keys(tool.input.properties), tool.name).not.toContain("sessionId"); } + const remember = writes.find((tool) => tool.name === "memory_remember"); + expect(Object.keys(remember!.input.properties)).not.toContain("copies"); + }); + + it("refuses copies that would carry a write out of the locked profile", async () => { + const requests = stubService(); + const tools = createMemoryAiTools(locked); + + await expect( + run(tools.memory_remember, { + content: "prefers short answers", + copies: [{ namespace: "support", profile: "user-43" }], + }), + ).rejects.toThrow('unknown property "copies"'); + expect(requests).toEqual([]); }); it("refuses a call that names another profile before any request", async () => { diff --git a/test/extensions.test.ts b/test/extensions.test.ts index eb92306..a712aea 100644 --- a/test/extensions.test.ts +++ b/test/extensions.test.ts @@ -200,7 +200,7 @@ describe("memory extensions", () => { await expect( remember.execute( "call-0", - { profile: "gsmg", content: "half" }, + { profile: "project", content: "half" }, signal, undefined, context, @@ -208,15 +208,15 @@ describe("memory extensions", () => { ).rejects.toThrow("pass namespace and profile together, or neither for default/oritwoen"); await remember.execute( "call-1", - { namespace: "puzzles", profile: "gsmg", content: "project fact" }, + { namespace: "research", profile: "project", content: "project fact" }, signal, undefined, context, ); expect(execute).toHaveBeenLastCalledWith( { - namespace: "puzzles", - profile: "gsmg", + namespace: "research", + profile: "project", content: "project fact", sessionId: expect.stringMatching(new RegExp(`^${harness}-movie-enigma-[0-9a-f]{16}$`)), }, @@ -567,11 +567,11 @@ describe("memory tool renderers", () => { for (const tools of [piTools(), ompTools()]) { const { text } = tool(tools, "memory_recall").renderCall!( - { namespace: "puzzles", profile: "gsmg", query: "elsewhere" }, + { namespace: "research", profile: "project", query: "elsewhere" }, {}, {}, ); - expect(text).toContain("puzzles/gsmg"); + expect(text).toContain("research/project"); expect(text).not.toContain("oritwoen"); } }); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 74b16fe..61f504f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -459,7 +459,7 @@ describe("memory MCP server", () => { expect(refused.content).toEqual([ { type: "text", - text: 'Invalid arguments: unknown property "sessionId"; takes namespace, profile, content', + text: 'Invalid arguments: unknown property "sessionId"; takes namespace, profile, content, copies', }, ]); expect(requests).toEqual([]); @@ -481,6 +481,59 @@ describe("memory MCP server", () => { ]); }); + it("stamps the harness session on every copy of a bound write", async () => { + vi.stubEnv("AGNTN_CLOUDFLARE_ACCOUNT_ID", "account"); + vi.stubEnv("CLOUDFLARE_API_TOKEN", "token"); + vi.stubEnv("AGNTN_MEMORY_NAMESPACE", "research"); + vi.stubEnv("AGNTN_MEMORY_PROFILE", "project"); + vi.stubEnv("CLAUDE_PROJECT_DIR", "/workspace/project"); + vi.stubEnv("CLAUDE_CODE_SESSION_ID", "conversation-123"); + const requests: Array<{ url: string; body: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ + url: String(input), + body: JSON.parse(String(init?.body)) as Record, + }); + return Response.json({ + success: true, + result: { + id: `memory-${requests.length}`, + type: "fact", + summary: "remembered", + content: "shared fact", + sessionId: null, + createdAt: "2026-08-18T00:00:00.000Z", + updatedAt: "2026-08-18T00:00:00.000Z", + }, + }); + }), + ); + const client = await connectTestClient(); + + const response = await client.callTool({ + name: "memory_remember", + arguments: { + content: "shared fact", + copies: [{ namespace: "research", profile: "shared" }], + }, + }); + + expect(response.isError).not.toBe(true); + const session = memorySessionId("claude", "/workspace/project", "conversation-123"); + expect(requests).toEqual([ + { + url: expect.stringContaining("/namespaces/research/profiles/project/remember"), + body: { content: "shared fact", sessionId: session }, + }, + { + url: expect.stringContaining("/namespaces/research/profiles/shared/remember"), + body: { content: "shared fact", sessionId: session }, + }, + ]); + }); + it("lets a bound call pick its own namespace and profile", async () => { vi.stubEnv("AGNTN_CLOUDFLARE_ACCOUNT_ID", "account"); vi.stubEnv("CLOUDFLARE_API_TOKEN", "token"); @@ -514,13 +567,13 @@ describe("memory MCP server", () => { const response = await client.callTool({ name: "memory_remember", - arguments: { namespace: "puzzles", profile: "gsmg", content: "a project fact" }, + arguments: { namespace: "research", profile: "project", content: "a project fact" }, }); expect(response.isError).not.toBe(true); expect(requests).toEqual([ { - url: expect.stringContaining("/namespaces/puzzles/profiles/gsmg/remember"), + url: expect.stringContaining("/namespaces/research/profiles/project/remember"), body: { content: "a project fact", sessionId: memorySessionId("claude", "/workspace/movie-enigma", "conversation-123"), @@ -542,7 +595,7 @@ describe("memory MCP server", () => { ]); requests.length = 0; - for (const half of [{ namespace: "puzzles" }, { profile: "gsmg" }]) { + for (const half of [{ namespace: "research" }, { profile: "project" }]) { const refused = await client.callTool({ name: "memory_remember", arguments: { ...half, content: "half a scope" }, diff --git a/test/tool-results.test.ts b/test/tool-results.test.ts index 2e06e08..ae2add0 100644 --- a/test/tool-results.test.ts +++ b/test/tool-results.test.ts @@ -2030,6 +2030,146 @@ describe("delete resilience", () => { }); }); +describe("remember copies", () => { + /** The `namespace/profile` a remember URL writes to. */ + function writeTarget(url: string): string { + const match = /\/namespaces\/([^/]+)\/profiles\/([^/]+)\/remember$/.exec(url); + return match ? `${match[1]}/${match[2]}` : url; + } + + /** Answer every write with a record named after its profile, or refuse the listed profiles. */ + function stubWrites(failing: readonly string[] = []): { urls: string[]; bodies: RequestInit[] } { + const bodies: RequestInit[] = []; + const urls = stubCloudflare((url, init) => { + bodies.push(init ?? {}); + const target = writeTarget(url); + if (failing.includes(target)) return { failure: { status: 400, message: "profile refused" } }; + return { + result: { + ...memoryEntry(`mem-${target.replace("/", "-")}`, "s-1"), + content: "shared fact", + }, + }; + }); + return { urls, bodies }; + } + + it("writes the same content and session to every profile, the main one first", async () => { + const { urls, bodies } = stubWrites(); + + const result = await memoryToolExecutors.remember({ + namespace: "research", + profile: "project", + content: "shared fact", + sessionId: "s-1", + copies: [ + { namespace: "research", profile: "shared" }, + { namespace: "team", profile: "agent" }, + ], + }); + + expect(urls.map((url) => writeTarget(url))).toEqual([ + "research/project", + "research/shared", + "team/agent", + ]); + for (const body of bodies) { + expect(JSON.parse(String(body.body))).toEqual({ content: "shared fact", sessionId: "s-1" }); + } + expect(textOf(result).split("\n")[0]).toBe( + "Stored the same memory in 3 profiles. A copy whose content matches the first record leaves it out.", + ); + expect(records<{ target: string; id: string; content?: string }>(result)).toEqual([ + expect.objectContaining({ target: "research/project", id: "mem-research-project" }), + expect.objectContaining({ target: "research/shared", id: "mem-research-shared" }), + expect.objectContaining({ target: "team/agent", id: "mem-team-agent" }), + ]); + expect(contentUnder(result, "mem-research-project")).toEqual(["shared fact"]); + expect(contentUnder(result, "mem-research-shared")).toEqual([]); + expect(textOf(result).match(/summaryTrust/g)).toHaveLength(3); + }); + + it("copies across namespaces, each write in its own namespace", async () => { + const { urls } = stubWrites(); + + await memoryToolExecutors.remember({ + namespace: "notes", + profile: "main", + content: "shared fact", + copies: [{ namespace: "topics", profile: "general" }], + }); + + expect(urls.map((url) => writeTarget(url))).toEqual(["notes/main", "topics/general"]); + }); + + it("names what landed and which copy to retry when a copy fails", async () => { + stubWrites(["team/agent"]); + + const error = await memoryToolExecutors + .remember({ + namespace: "research", + profile: "project", + content: "shared fact", + copies: [ + { namespace: "research", profile: "shared" }, + { namespace: "team", profile: "agent" }, + ], + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + const { message } = error as Error; + expect(message).toContain( + 'Stored memory in research/project "mem-research-project", research/shared "mem-research-shared", but 1 of 2 copies failed.', + ); + expect(message).toMatch(/team\/agent: .*profile refused/); + expect(message).toContain("retry only the profiles that lack the memory"); + }); + + it("writes no copy when the main profile refuses the memory", async () => { + const { urls } = stubWrites(["research/project"]); + + await expect( + memoryToolExecutors.remember({ + namespace: "research", + profile: "project", + content: "shared fact", + copies: [{ namespace: "research", profile: "shared" }], + }), + ).rejects.toThrow("profile refused"); + expect(urls).toHaveLength(1); + }); + + it.each([ + [[{ namespace: "research", profile: "project" }], 'Copies name "research/project" twice'], + [ + [ + { namespace: "research", profile: "shared" }, + { namespace: "research", profile: "shared" }, + ], + 'Copies name "research/shared" twice', + ], + [[{ namespace: "research", profile: ".." }], "Memory profile must match"], + [[], "Copies must be a list of 1 to 4 profiles"], + [ + Array.from({ length: 5 }, (_, index) => ({ namespace: "copy", profile: `p${index}` })), + "Copies must be a list of 1 to 4 profiles", + ], + ])("refuses copies %j before any write", async (copies, message) => { + const { urls } = stubWrites(); + + await expect( + memoryToolExecutors.remember({ + namespace: "research", + profile: "project", + content: "shared fact", + copies, + }), + ).rejects.toThrow(message); + expect(urls).toEqual([]); + }); +}); + describe("get result", () => { it("reads several memories in one call, keeping request order", async () => { const urls = stubCloudflare((url) => { diff --git a/test/tui.test.ts b/test/tui.test.ts index 615e17f..dba8ad9 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -78,6 +78,17 @@ describe("call lines", () => { expect(line.meta).toEqual(["messages 3"]); }); + it("counts the profiles a remember call copies into", () => { + const line = describeToolCall("Remember", { + namespace: "research", + profile: "project", + content: "shared fact", + copies: [{ namespace: "research", profile: "shared" }], + }); + + expect(line.meta).toEqual(["research/project", "copies 1"]); + }); + it("carries no icon when the harness reports no state", () => { expect(describeToolCall("Recall", { query: "q" }).icon).toBeUndefined(); });