+ {(IS_DEMO
+ ? scenarios[language].map((scenario) => scenario.question)
+ : [t("promptRecent"), t("promptTasks"), t("promptIdeas")]
+ ).map((question) => (
+
+ ))}
+
+ );
+
+ const send = async () => {
+ const query = input.trim();
+ if (!query || tab.streaming) return;
+ setInput("");
+ const user: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "user",
+ content: query,
+ };
+ const assistant: ChatMessage = {
+ id: crypto.randomUUID(),
+ role: "assistant",
+ content: "",
+ blocks: [],
+ };
+ addChatTurn(tab.id, user, assistant);
+ const requestController = new AbortController();
+ controller.current = requestController;
+ const error = await chatStreamError(
+ () =>
+ streamChat(query, tab.sessionId, requestController.signal, (chunk) =>
+ applyChatChunk(tab.id, assistant.id, chunk),
+ ),
+ requestController.signal,
+ t("chatFailed"),
+ );
+ finishChat(tab.id, assistant.id, error);
+ if (controller.current === requestController) controller.current = null;
+ };
+
+ return (
+
+
+ {!tab.messages.length && (
+
+
+
{t("chatTitle")}
+
{t(IS_DEMO ? "demoChatDescription" : "chatDescription")}
+ {suggestions}
+
+ )}
+ {tab.messages.map((message) => (
+
+
+ {message.role === "user" ? (
+ t("you")
+ ) : (
+
+ )}
+
+
+ {message.role === "user" &&
{message.content}
}
+ {message.role === "assistant" &&
+ message.blocks?.map((block) => (
+
+ toggleChatBlock(tab.id, message.id, blockId, expanded)
+ }
+ />
+ ))}
+ {message.role === "assistant" &&
+ tab.streaming &&
+ !message.blocks?.some((block) => block.type !== "usage") && (
+
+ )}
+
+
+ ))}
+ {IS_DEMO && tab.messages.length > 0 && suggestions}
+
+
+
{
+ if (!event.dataTransfer.types.includes(WORKSPACE_FILE_DRAG_TYPE))
+ return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ setFileDragOver(true);
+ }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null))
+ setFileDragOver(false);
+ }}
+ onDrop={(event) => {
+ const path = event.dataTransfer.getData(WORKSPACE_FILE_DRAG_TYPE);
+ if (!path) return;
+ event.preventDefault();
+ setFileDragOver(false);
+ setInput((current) => appendWorkspaceFileReference(current, path));
+ }}
+ >
+
+
+
{t(IS_DEMO ? "demoChatHint" : "composerHint")}
+
+
+ );
+}
diff --git a/reme_studio/app/workspace-tabs.tsx b/reme_studio/app/workspace-tabs.tsx
new file mode 100644
index 00000000..97f225cc
--- /dev/null
+++ b/reme_studio/app/workspace-tabs.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Bot, FileText, Network, X } from "lucide-react";
+import { useI18n } from "./i18n";
+import { useWorkspaceStore } from "./store";
+import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
+
+export default function Tabs() {
+ const { t } = useI18n();
+ const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs } =
+ useWorkspaceStore();
+ const [contextMenu, setContextMenu] = useState<{
+ tabId: string;
+ left: number;
+ top: number;
+ }>();
+ const menuRef = useRef(null);
+ useEffect(() => {
+ if (!contextMenu) return;
+ const close = (event: PointerEvent) => {
+ if (!menuRef.current?.contains(event.target as Node))
+ setContextMenu(undefined);
+ };
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setContextMenu(undefined);
+ };
+ const closeOnViewportChange = () => setContextMenu(undefined);
+ document.addEventListener("pointerdown", close);
+ document.addEventListener("keydown", closeOnEscape);
+ window.addEventListener("resize", closeOnViewportChange);
+ window.addEventListener("scroll", closeOnViewportChange, true);
+ return () => {
+ document.removeEventListener("pointerdown", close);
+ document.removeEventListener("keydown", closeOnEscape);
+ window.removeEventListener("resize", closeOnViewportChange);
+ window.removeEventListener("scroll", closeOnViewportChange, true);
+ };
+ }, [contextMenu]);
+ const confirmDiscard = (tabId: string, closeOthers: boolean) => {
+ const unsaved = unsavedTabsClosedBy(tabs, tabId, closeOthers);
+ return (
+ !unsaved.length ||
+ window.confirm(
+ t("discardUnsavedConfirm", { count: String(unsaved.length) }),
+ )
+ );
+ };
+ return (
+ <>
+
+ {tabs.map((tab) => {
+ const dirty = hasUnsavedChanges(tab);
+ return (
+
+ );
+ })}
+
+ {contextMenu && (
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/reme_studio/app/workspace-theme.tsx b/reme_studio/app/workspace-theme.tsx
new file mode 100644
index 00000000..690c8653
--- /dev/null
+++ b/reme_studio/app/workspace-theme.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { Check, Monitor, Moon, Sun, SunMoon } from "lucide-react";
+import { useI18n } from "./i18n";
+import { type ThemePreference, useThemeStore } from "./theme";
+
+export default function ThemeMenu() {
+ const { t } = useI18n();
+ const menuRef = useRef(null);
+ const preference = useThemeStore((state) => state.preference);
+ const setPreference = useThemeStore((state) => state.setPreference);
+ useEffect(() => {
+ const close = (event: PointerEvent) => {
+ if (!menuRef.current?.contains(event.target as Node))
+ menuRef.current?.removeAttribute("open");
+ };
+ document.addEventListener("pointerdown", close);
+ return () => document.removeEventListener("pointerdown", close);
+ }, []);
+ const options: Array<[ThemePreference, string, React.ReactNode]> = [
+ ["light", t("lightTheme"), ],
+ ["dark", t("darkTheme"), ],
+ ["system", t("systemTheme"), ],
+ ];
+ return (
+
+
+
+
+
+ {options.map(([value, label, icon]) => (
+
+ ))}
+
+
+ );
+}
diff --git a/reme_studio/app/workspace.tsx b/reme_studio/app/workspace.tsx
index b8aa450a..27beb2d9 100644
--- a/reme_studio/app/workspace.tsx
+++ b/reme_studio/app/workspace.tsx
@@ -1,55 +1,29 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
-import WorkspaceMarkdown from "./files-workspace/WorkspaceMarkdown";
import { IS_DEMO } from "./studio-mode";
-import { openWorkspaceFile } from "./open-file";
+import { loadWorkspaceFile, openWorkspaceFile } from "./open-file";
import { SparkMenuExpandLine, SparkMenuFoldLine } from "@agentscope-ai/icons";
-import {
- BookOpenText,
- Bot,
- Check,
- CircleAlert,
- FileText,
- LoaderCircle,
- Monitor,
- Moon,
- Network,
- Send,
- Settings,
- Sparkles,
- Sun,
- SunMoon,
- X,
-} from "lucide-react";
-import { readWorkspaceFile, streamChat } from "./api";
-import { chatStreamError, formatStreamPayloads } from "./chat-stream";
+import { BookOpenText, Settings, Sparkles } from "lucide-react";
import FilesNavigator from "./files-workspace/FilesNavigator";
import MemoryGraphView from "./files-workspace/MemoryGraphView";
import { clampNavigatorWidth } from "./files-workspace/panel-resize";
import { useI18n, useLanguageStore } from "./i18n";
import { useWorkspaceStore } from "./store";
-import { type ThemePreference, useThemeStore } from "./theme";
-import type {
- ChatBlock,
- ChatMessage,
- DetailBlock,
- WorkspaceTab,
-} from "./types";
-import {
- appendWorkspaceFileReference,
- WORKSPACE_FILE_DRAG_TYPE,
-} from "./workspace-drag";
+import { useThemeStore } from "./theme";
+import type { WorkspaceTab } from "./types";
+import ThemeMenu from "./workspace-theme";
+import Tabs from "./workspace-tabs";
+import Chat from "./workspace-chat";
+import packageJson from "../package.json";
+
const SettingsCenter = IS_DEMO
? null
: dynamic(() => import("./settings-center"), { ssr: false });
const DemoBar = IS_DEMO
? dynamic(() => import("../demo/DemoBar"), { ssr: false })
: null;
-import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close";
-import packageJson from "../package.json";
-import { scenarios } from "../demo/scenarios";
const TabbedEditor = dynamic(() => import("./files-workspace/TabbedEditor"), {
ssr: false,
@@ -69,451 +43,6 @@ function GitHubIcon({ size = 18 }: { size?: number }) {
);
}
-function ThemeMenu() {
- const { t } = useI18n();
- const menuRef = useRef(null);
- const preference = useThemeStore((state) => state.preference);
- const setPreference = useThemeStore((state) => state.setPreference);
- useEffect(() => {
- const close = (event: PointerEvent) => {
- if (!menuRef.current?.contains(event.target as Node))
- menuRef.current?.removeAttribute("open");
- };
- document.addEventListener("pointerdown", close);
- return () => document.removeEventListener("pointerdown", close);
- }, []);
- const options: Array<[ThemePreference, string, React.ReactNode]> = [
- ["light", t("lightTheme"), ],
- ["dark", t("darkTheme"), ],
- ["system", t("systemTheme"), ],
- ];
- return (
-
-
-
-
-
- {options.map(([value, label, icon]) => (
-
- ))}
-
-
- );
-}
-
-function Tabs() {
- const { t } = useI18n();
- const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs } =
- useWorkspaceStore();
- const [contextMenu, setContextMenu] = useState<{
- tabId: string;
- left: number;
- top: number;
- }>();
- const menuRef = useRef(null);
- useEffect(() => {
- if (!contextMenu) return;
- const close = (event: PointerEvent) => {
- if (!menuRef.current?.contains(event.target as Node))
- setContextMenu(undefined);
- };
- const closeOnEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") setContextMenu(undefined);
- };
- const closeOnViewportChange = () => setContextMenu(undefined);
- document.addEventListener("pointerdown", close);
- document.addEventListener("keydown", closeOnEscape);
- window.addEventListener("resize", closeOnViewportChange);
- window.addEventListener("scroll", closeOnViewportChange, true);
- return () => {
- document.removeEventListener("pointerdown", close);
- document.removeEventListener("keydown", closeOnEscape);
- window.removeEventListener("resize", closeOnViewportChange);
- window.removeEventListener("scroll", closeOnViewportChange, true);
- };
- }, [contextMenu]);
- const confirmDiscard = (tabId: string, closeOthers: boolean) => {
- const unsaved = unsavedTabsClosedBy(tabs, tabId, closeOthers);
- return (
- !unsaved.length ||
- window.confirm(
- t("discardUnsavedConfirm", { count: String(unsaved.length) }),
- )
- );
- };
- return (
- <>
-
- {tabs.map((tab) => {
- const dirty = hasUnsavedChanges(tab);
- return (
-
- );
- })}
-
- {contextMenu && (
-
-
-
-
- )}
- >
- );
-}
-
-function MarkdownView({ content }: { content: string }) {
- return (
-
-
-
- );
-}
-
-function ChunkStatus({
- active,
- error = false,
-}: {
- active: boolean;
- error?: boolean;
-}) {
- if (error) return ;
- return active ? (
-
- ) : (
-
- );
-}
-
-function detailLabel(
- block: DetailBlock,
- t: ReturnType["t"],
-): string {
- if (block.type === "think") return t("thinking");
- if (block.type === "data")
- return block.mediaType
- ? `${t("dataChunk")} · ${block.mediaType}`
- : t("dataChunk");
- if (block.type === "approval") return t("approvalChunk");
- if (block.type === "usage") return t("usageChunk");
- return t("unknownChunk", { type: block.sourceType });
-}
-
-function ChatBlockView({
- block,
- onToggle,
-}: {
- block: ChatBlock;
- onToggle: (blockId: string, expanded: boolean) => void;
-}) {
- const { t } = useI18n();
- if (block.type === "content") return ;
- if (block.type === "error")
- return (
-
-
- {block.text}
-
- );
- if (block.type === "usage") return null;
-
- const active =
- block.status === "streaming" ||
- block.status === "calling" ||
- block.status === "running";
- const failed = block.status === "error";
- if (block.type === "tool") {
- const call = formatStreamPayloads(block.callPayloads);
- const result = formatStreamPayloads(block.resultPayloads);
- return (
- {
- if (event.currentTarget.open !== block.expanded)
- onToggle(block.id, event.currentTarget.open);
- }}
- >
-
-
- {block.name}
- {active ? t("streaming") : t("completed")}
-
-
- {call && (
-
- {t("toolCall")}
- {call}
-
- )}
- {result && (
-
- {t("toolResult")}
- {result}
-
- )}
-
-
- );
- }
-
- const detail = formatStreamPayloads(block.payloads);
- return (
- {
- if (event.currentTarget.open !== block.expanded)
- onToggle(block.id, event.currentTarget.open);
- }}
- >
-
-
- {detailLabel(block, t)}
- {active ? t("streaming") : t("completed")}
-
- {detail && (
-
- )}
-
- );
-}
-
-function Chat({ tab }: { tab: Extract }) {
- const { language, t } = useI18n();
- const [input, setInput] = useState("");
- const [fileDragOver, setFileDragOver] = useState(false);
- const endRef = useRef(null);
- const controller = useRef(null);
- const { addChatTurn, applyChatChunk, toggleChatBlock, finishChat } =
- useWorkspaceStore();
-
- useEffect(() => {
- endRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [tab.messages]);
- useEffect(() => () => controller.current?.abort(), []);
-
- const suggestions = (
-
- {(IS_DEMO
- ? scenarios[language].map((scenario) => scenario.question)
- : [t("promptRecent"), t("promptTasks"), t("promptIdeas")]
- ).map((question) => (
-
- ))}
-
- );
-
- const send = async () => {
- const query = input.trim();
- if (!query || tab.streaming) return;
- setInput("");
- const user: ChatMessage = {
- id: crypto.randomUUID(),
- role: "user",
- content: query,
- };
- const assistant: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- content: "",
- blocks: [],
- };
- addChatTurn(tab.id, user, assistant);
- const requestController = new AbortController();
- controller.current = requestController;
- const error = await chatStreamError(
- () =>
- streamChat(query, tab.sessionId, requestController.signal, (chunk) =>
- applyChatChunk(tab.id, assistant.id, chunk),
- ),
- requestController.signal,
- t("chatFailed"),
- );
- finishChat(tab.id, assistant.id, error);
- if (controller.current === requestController) controller.current = null;
- };
-
- return (
-
-
- {!tab.messages.length && (
-
-
-
{t("chatTitle")}
-
{t(IS_DEMO ? "demoChatDescription" : "chatDescription")}
- {suggestions}
-
- )}
- {tab.messages.map((message) => (
-
-
- {message.role === "user" ? (
- t("you")
- ) : (
-
- )}
-
-
- {message.role === "user" &&
{message.content}
}
- {message.role === "assistant" &&
- message.blocks?.map((block) => (
-
- toggleChatBlock(tab.id, message.id, blockId, expanded)
- }
- />
- ))}
- {message.role === "assistant" &&
- tab.streaming &&
- !message.blocks?.some((block) => block.type !== "usage") && (
-
- )}
-
-
- ))}
- {IS_DEMO && tab.messages.length > 0 && suggestions}
-
-
-
{
- if (!event.dataTransfer.types.includes(WORKSPACE_FILE_DRAG_TYPE))
- return;
- event.preventDefault();
- event.dataTransfer.dropEffect = "copy";
- setFileDragOver(true);
- }}
- onDragLeave={(event) => {
- if (!event.currentTarget.contains(event.relatedTarget as Node | null))
- setFileDragOver(false);
- }}
- onDrop={(event) => {
- const path = event.dataTransfer.getData(WORKSPACE_FILE_DRAG_TYPE);
- if (!path) return;
- event.preventDefault();
- setFileDragOver(false);
- setInput((current) => appendWorkspaceFileReference(current, path));
- }}
- >
-
-
-
{t(IS_DEMO ? "demoChatHint" : "composerHint")}
-
-
- );
-}
-
function Workspace() {
const { language, setLanguage, t } = useI18n();
const hydrateLanguage = useLanguageStore((state) => state.hydrate);
@@ -522,8 +51,7 @@ function Workspace() {
const [navigatorWidth, setNavigatorWidth] = useState(260);
const [resizingNavigator, setResizingNavigator] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
- const { tabs, activeTabId, openAgent, hydrateMarkdown, failMarkdown } =
- useWorkspaceStore();
+ const { tabs, activeTabId, openAgent } = useWorkspaceStore();
const active = tabs.find((tab) => tab.id === activeTabId);
useEffect(() => {
let mounted = true;
@@ -539,28 +67,16 @@ function Workspace() {
tab.type === "markdown" && Boolean(tab.loading),
);
await Promise.all(
- restoredFiles.map(async (tab) => {
- try {
- const file = await readWorkspaceFile(tab.path);
- if (mounted)
- hydrateMarkdown(tab.id, file.content, file.stat.mtime);
- } catch (error) {
- if (mounted)
- failMarkdown(
- tab.id,
- error instanceof Error
- ? error.message
- : "Failed to read file",
- );
- }
- }),
+ restoredFiles.map((tab) =>
+ loadWorkspaceFile(tab.id, tab.path, () => mounted),
+ ),
);
},
);
return () => {
mounted = false;
};
- }, [failMarkdown, hydrateMarkdown]);
+ }, []);
useEffect(() => {
hydrateLanguage();
}, [hydrateLanguage]);
diff --git a/reme_studio/cloudflare-env.d.ts b/reme_studio/cloudflare-env.d.ts
deleted file mode 100644
index ec8799b9..00000000
--- a/reme_studio/cloudflare-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare namespace Cloudflare {
- interface Env {
- DB: D1Database;
- }
-}
diff --git a/reme_studio/db/index.ts b/reme_studio/db/index.ts
deleted file mode 100644
index f510ccb4..00000000
--- a/reme_studio/db/index.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { env } from "cloudflare:workers";
-import { drizzle } from "drizzle-orm/d1";
-import * as schema from "./schema";
-
-export function getDb() {
- if (!env.DB) {
- throw new Error(
- "Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database.",
- );
- }
-
- return drizzle(env.DB, { schema });
-}
diff --git a/reme_studio/db/schema.ts b/reme_studio/db/schema.ts
deleted file mode 100644
index d2dd83cd..00000000
--- a/reme_studio/db/schema.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// Intentionally empty by default.
-// Add Drizzle tables here when the site actually needs a database.
-// See examples/d1/db/schema.ts for an opt-in example.
-export {};
diff --git a/reme_studio/drizzle.config.ts b/reme_studio/drizzle.config.ts
deleted file mode 100644
index c2b2f343..00000000
--- a/reme_studio/drizzle.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from "drizzle-kit";
-
-export default defineConfig({
- out: "./drizzle",
- schema: "./db/schema.ts",
- dialect: "sqlite",
-});
diff --git a/reme_studio/drizzle/meta/_journal.json b/reme_studio/drizzle/meta/_journal.json
deleted file mode 100644
index 43775074..00000000
--- a/reme_studio/drizzle/meta/_journal.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "version": "7",
- "dialect": "sqlite",
- "entries": []
-}
diff --git a/reme_studio/examples/d1/app/api/notes/route.ts b/reme_studio/examples/d1/app/api/notes/route.ts
deleted file mode 100644
index 56315619..00000000
--- a/reme_studio/examples/d1/app/api/notes/route.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { desc } from "drizzle-orm";
-import { getDb } from "../../../../../db";
-import { notes } from "../../../db/schema";
-
-function toRouteErrorMessage(error: unknown) {
- const message = error instanceof Error ? error.message : "Unexpected error";
- const detail =
- error instanceof Error && error.cause instanceof Error
- ? error.cause.message
- : "";
- const combined = `${message}\n${detail}`;
-
- if (combined.includes("no such table") || combined.includes('from "notes"')) {
- return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
- }
-
- return message;
-}
-
-export async function GET() {
- try {
- const db = getDb();
- const rows = await db
- .select()
- .from(notes)
- .orderBy(desc(notes.createdAt), desc(notes.id))
- .limit(20);
-
- return Response.json({ notes: rows });
- } catch (error) {
- return Response.json(
- { error: toRouteErrorMessage(error) },
- { status: 500 },
- );
- }
-}
-
-export async function POST(request: Request) {
- try {
- const payload = (await request.json()) as {
- title?: string;
- content?: string;
- };
- const title = payload.title?.trim() ?? "";
- const content = payload.content?.trim() ?? "";
-
- if (!title) {
- return Response.json({ error: "title is required" }, { status: 400 });
- }
-
- const db = getDb();
- const [note] = await db
- .insert(notes)
- .values({ title, content })
- .returning();
- return Response.json({ note }, { status: 201 });
- } catch (error) {
- return Response.json(
- { error: toRouteErrorMessage(error) },
- { status: 500 },
- );
- }
-}
diff --git a/reme_studio/examples/d1/db/schema.ts b/reme_studio/examples/d1/db/schema.ts
deleted file mode 100644
index 0fbea27b..00000000
--- a/reme_studio/examples/d1/db/schema.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { sql } from "drizzle-orm";
-import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
-
-export const notes = sqliteTable("notes", {
- id: integer("id").primaryKey({ autoIncrement: true }),
- title: text("title").notNull(),
- content: text("content").notNull().default(""),
- createdAt: text("created_at")
- .notNull()
- .default(sql`CURRENT_TIMESTAMP`),
-});
diff --git a/reme_studio/package-lock.json b/reme_studio/package-lock.json
index c6bd5fa3..014b5f4f 100644
--- a/reme_studio/package-lock.json
+++ b/reme_studio/package-lock.json
@@ -21,8 +21,6 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
- "drizzle-kit": "0.31.10",
- "drizzle-orm": "0.45.2",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
@@ -473,13 +471,6 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
- "node_modules/@drizzle-team/brocli": {
- "version": "0.10.2",
- "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
- "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
- "dev": true,
- "license": "Apache-2.0"
- },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -491,496 +482,6 @@
"tslib": "^2.4.0"
}
},
- "node_modules/@esbuild-kit/core-utils": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
- "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==",
- "deprecated": "Merged into tsx: https://tsx.is",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "~0.18.20",
- "source-map-support": "^0.5.21"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
- "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
- "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
- "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
- "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
- "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
- "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
- "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
- "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
- "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
- "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
- "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
- "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
- "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
- "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
- "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
- "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
- "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
- "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
- "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
- "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
- "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
- "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
- "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
- "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
- "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
- "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.0",
- "@esbuild/android-arm": "0.25.0",
- "@esbuild/android-arm64": "0.25.0",
- "@esbuild/android-x64": "0.25.0",
- "@esbuild/darwin-arm64": "0.25.0",
- "@esbuild/darwin-x64": "0.25.0",
- "@esbuild/freebsd-arm64": "0.25.0",
- "@esbuild/freebsd-x64": "0.25.0",
- "@esbuild/linux-arm": "0.25.0",
- "@esbuild/linux-arm64": "0.25.0",
- "@esbuild/linux-ia32": "0.25.0",
- "@esbuild/linux-loong64": "0.25.0",
- "@esbuild/linux-mips64el": "0.25.0",
- "@esbuild/linux-ppc64": "0.25.0",
- "@esbuild/linux-riscv64": "0.25.0",
- "@esbuild/linux-s390x": "0.25.0",
- "@esbuild/linux-x64": "0.25.0",
- "@esbuild/netbsd-arm64": "0.25.0",
- "@esbuild/netbsd-x64": "0.25.0",
- "@esbuild/openbsd-arm64": "0.25.0",
- "@esbuild/openbsd-x64": "0.25.0",
- "@esbuild/sunos-x64": "0.25.0",
- "@esbuild/win32-arm64": "0.25.0",
- "@esbuild/win32-ia32": "0.25.0",
- "@esbuild/win32-x64": "0.25.0"
- }
- },
- "node_modules/@esbuild-kit/esm-loader": {
- "version": "2.6.5",
- "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
- "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==",
- "deprecated": "Merged into tsx: https://tsx.is",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@esbuild-kit/core-utils": "^3.3.2",
- "get-tsconfig": "^4.7.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -994,6 +495,7 @@
"os": [
"aix"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1011,6 +513,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1028,6 +531,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1045,6 +549,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1062,6 +567,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1079,6 +585,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1096,6 +603,7 @@
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1113,6 +621,7 @@
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1130,6 +639,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1147,6 +657,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1164,6 +675,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1181,6 +693,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1198,6 +711,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1215,6 +729,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1232,6 +747,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1249,6 +765,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1266,6 +783,7 @@
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1283,6 +801,7 @@
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1300,6 +819,7 @@
"os": [
"netbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1317,6 +837,7 @@
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1334,6 +855,7 @@
"os": [
"openbsd"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1351,6 +873,7 @@
"os": [
"openharmony"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1368,6 +891,7 @@
"os": [
"sunos"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1385,6 +909,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1402,6 +927,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -1419,6 +945,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -4173,7 +3700,8 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/call-bind": {
"version": "1.0.9",
@@ -4677,632 +4205,6 @@
"@types/trusted-types": "^2.0.7"
}
},
- "node_modules/drizzle-kit": {
- "version": "0.31.10",
- "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
- "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@drizzle-team/brocli": "^0.10.2",
- "@esbuild-kit/esm-loader": "^2.5.5",
- "esbuild": "^0.25.4",
- "tsx": "^4.21.0"
- },
- "bin": {
- "drizzle-kit": "bin.cjs"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
- "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
- "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
- "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
- "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
- "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
- "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
- "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
- "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
- "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
- "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
- "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
- "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
- "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
- "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
- "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
- "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
- "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
- "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
- "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
- "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
- "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
- "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
- "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
- "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/drizzle-kit/node_modules/esbuild": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
- "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.12",
- "@esbuild/android-arm": "0.25.12",
- "@esbuild/android-arm64": "0.25.12",
- "@esbuild/android-x64": "0.25.12",
- "@esbuild/darwin-arm64": "0.25.12",
- "@esbuild/darwin-x64": "0.25.12",
- "@esbuild/freebsd-arm64": "0.25.12",
- "@esbuild/freebsd-x64": "0.25.12",
- "@esbuild/linux-arm": "0.25.12",
- "@esbuild/linux-arm64": "0.25.12",
- "@esbuild/linux-ia32": "0.25.12",
- "@esbuild/linux-loong64": "0.25.12",
- "@esbuild/linux-mips64el": "0.25.12",
- "@esbuild/linux-ppc64": "0.25.12",
- "@esbuild/linux-riscv64": "0.25.12",
- "@esbuild/linux-s390x": "0.25.12",
- "@esbuild/linux-x64": "0.25.12",
- "@esbuild/netbsd-arm64": "0.25.12",
- "@esbuild/netbsd-x64": "0.25.12",
- "@esbuild/openbsd-arm64": "0.25.12",
- "@esbuild/openbsd-x64": "0.25.12",
- "@esbuild/openharmony-arm64": "0.25.12",
- "@esbuild/sunos-x64": "0.25.12",
- "@esbuild/win32-arm64": "0.25.12",
- "@esbuild/win32-ia32": "0.25.12",
- "@esbuild/win32-x64": "0.25.12"
- }
- },
- "node_modules/drizzle-orm": {
- "version": "0.45.2",
- "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
- "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
- "dev": true,
- "license": "Apache-2.0",
- "peerDependencies": {
- "@aws-sdk/client-rds-data": ">=3",
- "@cloudflare/workers-types": ">=4",
- "@electric-sql/pglite": ">=0.2.0",
- "@libsql/client": ">=0.10.0",
- "@libsql/client-wasm": ">=0.10.0",
- "@neondatabase/serverless": ">=0.10.0",
- "@op-engineering/op-sqlite": ">=2",
- "@opentelemetry/api": "^1.4.1",
- "@planetscale/database": ">=1.13",
- "@prisma/client": "*",
- "@tidbcloud/serverless": "*",
- "@types/better-sqlite3": "*",
- "@types/pg": "*",
- "@types/sql.js": "*",
- "@upstash/redis": ">=1.34.7",
- "@vercel/postgres": ">=0.8.0",
- "@xata.io/client": "*",
- "better-sqlite3": ">=7",
- "bun-types": "*",
- "expo-sqlite": ">=14.0.0",
- "gel": ">=2",
- "knex": "*",
- "kysely": "*",
- "mysql2": ">=2",
- "pg": ">=8",
- "postgres": ">=3",
- "sql.js": ">=1",
- "sqlite3": ">=5"
- },
- "peerDependenciesMeta": {
- "@aws-sdk/client-rds-data": {
- "optional": true
- },
- "@cloudflare/workers-types": {
- "optional": true
- },
- "@electric-sql/pglite": {
- "optional": true
- },
- "@libsql/client": {
- "optional": true
- },
- "@libsql/client-wasm": {
- "optional": true
- },
- "@neondatabase/serverless": {
- "optional": true
- },
- "@op-engineering/op-sqlite": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@planetscale/database": {
- "optional": true
- },
- "@prisma/client": {
- "optional": true
- },
- "@tidbcloud/serverless": {
- "optional": true
- },
- "@types/better-sqlite3": {
- "optional": true
- },
- "@types/pg": {
- "optional": true
- },
- "@types/sql.js": {
- "optional": true
- },
- "@upstash/redis": {
- "optional": true
- },
- "@vercel/postgres": {
- "optional": true
- },
- "@xata.io/client": {
- "optional": true
- },
- "better-sqlite3": {
- "optional": true
- },
- "bun-types": {
- "optional": true
- },
- "expo-sqlite": {
- "optional": true
- },
- "gel": {
- "optional": true
- },
- "knex": {
- "optional": true
- },
- "kysely": {
- "optional": true
- },
- "mysql2": {
- "optional": true
- },
- "pg": {
- "optional": true
- },
- "postgres": {
- "optional": true
- },
- "prisma": {
- "optional": true
- },
- "sql.js": {
- "optional": true
- },
- "sqlite3": {
- "optional": true
- }
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5557,6 +4459,8 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -6197,19 +5101,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/get-tsconfig": {
- "version": "4.14.0",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
- "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "resolve-pkg-maps": "^1.0.0"
- },
- "funding": {
- "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
- }
- },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -9311,16 +8202,6 @@
"node": ">=4"
}
},
- "node_modules/resolve-pkg-maps": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
- }
- },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -9757,6 +8638,7 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9777,6 +8659,7 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -10261,6 +9144,8 @@
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
+ "optional": true,
+ "peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
diff --git a/reme_studio/package.json b/reme_studio/package.json
index f76c8d32..2305d485 100644
--- a/reme_studio/package.json
+++ b/reme_studio/package.json
@@ -28,14 +28,15 @@
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"build:static": "vite build --config vite.static.config.ts",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
- "test": "npm run build && npm run build:static && npm run build:demo && node --test tests/*.test.mjs",
+ "test": "npm run test:unit && npm run test:build",
"lint": "eslint . --ignore-pattern dist --ignore-pattern dist-static --ignore-pattern dist-demo --ignore-pattern .next",
"format": "tsc --noEmit && prettier --write .",
"format:check": "tsc --noEmit && prettier --check .",
- "db:generate": "drizzle-kit generate",
"prepack": "npm run build:static",
"build:demo": "vite build --config vite.static.config.ts --mode demo",
- "dev:demo": "vite --config vite.static.config.ts --mode demo"
+ "dev:demo": "vite --config vite.static.config.ts --mode demo",
+ "test:unit": "node --test tests/*.test.mjs",
+ "test:build": "npm run build && npm run build:static && npm run build:demo && node --test tests/distribution/*.test.mjs"
},
"devDependencies": {
"@agentscope-ai/icons": "^1.0.67",
@@ -50,8 +51,6 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
- "drizzle-kit": "0.31.10",
- "drizzle-orm": "0.45.2",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
@@ -75,9 +74,6 @@
"zustand": "^5.0.14"
},
"overrides": {
- "@esbuild-kit/core-utils": {
- "esbuild": "0.25.0"
- },
"dompurify": "3.4.13"
},
"type": "module"
diff --git a/reme_studio/public/file.svg b/reme_studio/public/file.svg
deleted file mode 100644
index 16fe3d3a..00000000
--- a/reme_studio/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/reme_studio/public/globe.svg b/reme_studio/public/globe.svg
deleted file mode 100644
index c7215fe0..00000000
--- a/reme_studio/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/reme_studio/public/window.svg b/reme_studio/public/window.svg
deleted file mode 100644
index d05e7a1b..00000000
--- a/reme_studio/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/reme_studio/tests/demo-build.test.mjs b/reme_studio/tests/distribution/demo-build.test.mjs
similarity index 93%
rename from reme_studio/tests/demo-build.test.mjs
rename to reme_studio/tests/distribution/demo-build.test.mjs
index f12bde2e..7e88bfe7 100644
--- a/reme_studio/tests/demo-build.test.mjs
+++ b/reme_studio/tests/distribution/demo-build.test.mjs
@@ -3,7 +3,7 @@ import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
test("the browser demo builds for a subdirectory with no service API or live settings", async () => {
- const directory = new URL("../dist-demo/", import.meta.url);
+ const directory = new URL("../../dist-demo/", import.meta.url);
const html = await readFile(new URL("index.html", directory), "utf8");
assert.match(html, /Try ReMe Studio<\/title>/);
assert.match(html, /src="\.\/assets\//);
diff --git a/reme_studio/tests/rendered-html.test.mjs b/reme_studio/tests/distribution/rendered-html.test.mjs
similarity index 88%
rename from reme_studio/tests/rendered-html.test.mjs
rename to reme_studio/tests/distribution/rendered-html.test.mjs
index a580ab88..97526489 100644
--- a/reme_studio/tests/rendered-html.test.mjs
+++ b/reme_studio/tests/distribution/rendered-html.test.mjs
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
import test from "node:test";
async function render() {
- const workerUrl = new URL("../dist/server/index.js", import.meta.url);
+ const workerUrl = new URL("../../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
@@ -23,7 +23,7 @@ test("server-renders the ReMe Studio shell", async () => {
const html = await response.text();
const packageJson = JSON.parse(
- await readFile(new URL("../package.json", import.meta.url), "utf8"),
+ await readFile(new URL("../../package.json", import.meta.url), "utf8"),
);
assert.match(html, /ReMe Studio<\/title>/i);
assert.match(
diff --git a/reme_studio/tests/static-build.test.mjs b/reme_studio/tests/distribution/static-build.test.mjs
similarity index 74%
rename from reme_studio/tests/static-build.test.mjs
rename to reme_studio/tests/distribution/static-build.test.mjs
index d6c4d00c..0fbb9904 100644
--- a/reme_studio/tests/static-build.test.mjs
+++ b/reme_studio/tests/distribution/static-build.test.mjs
@@ -4,20 +4,22 @@ import test from "node:test";
import { fileURLToPath } from "node:url";
test("static build contains the ReMe workspace entry and assets", async () => {
- const output = fileURLToPath(new URL("../dist-static/", import.meta.url));
+ const output = fileURLToPath(new URL("../../dist-static/", import.meta.url));
const html = await readFile(
- new URL("../dist-static/index.html", import.meta.url),
+ new URL("../../dist-static/index.html", import.meta.url),
"utf8",
);
const assets = await readdir(
- new URL("../dist-static/assets/", import.meta.url),
+ new URL("../../dist-static/assets/", import.meta.url),
);
const javascript = (
await Promise.all(
assets
.filter((name) => name.endsWith(".js"))
.map((name) =>
- readFile(new URL(`../dist-static/assets/${name}`, import.meta.url)),
+ readFile(
+ new URL(`../../dist-static/assets/${name}`, import.meta.url),
+ ),
),
)
).join("\n");
diff --git a/reme_studio/tooling/sites-vite-plugin.ts b/reme_studio/tooling/sites-vite-plugin.ts
index 26563fdd..edfc4f83 100644
--- a/reme_studio/tooling/sites-vite-plugin.ts
+++ b/reme_studio/tooling/sites-vite-plugin.ts
@@ -14,7 +14,7 @@ async function exists(path: string): Promise {
}
}
-// Packages Sites metadata and migrations after Vite finishes compiling.
+// Packages Sites metadata after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
@@ -27,7 +27,6 @@ export function sites(): Plugin {
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
- const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
@@ -35,11 +34,6 @@ export function sites(): Plugin {
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
- if (await exists(drizzleSource)) {
- await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
- recursive: true,
- });
- }
},
};
}
diff --git a/reme_studio/worker/index.ts b/reme_studio/worker/index.ts
index 6aacdd4e..b942cdcf 100644
--- a/reme_studio/worker/index.ts
+++ b/reme_studio/worker/index.ts
@@ -8,7 +8,6 @@ import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
- DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record): {
diff --git a/tests/unit/file_store_test_support.py b/tests/unit/file_store_test_support.py
new file mode 100644
index 00000000..9465e466
--- /dev/null
+++ b/tests/unit/file_store_test_support.py
@@ -0,0 +1,87 @@
+"""Small shared constructors for local, FAISS, and zvec store contract tests."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import time
+
+import numpy as np
+import pytest
+
+from reme.components.file_store import FaissLocalFileStore, LocalFileStore, ZvecLocalFileStore
+from reme.schema import FileChunk, FileNode
+
+
+class FakeEmbeddingStore:
+ """Small deterministic embedding provider used by file-store tests."""
+
+ dimensions = 2
+ max_batch_size = 10
+ is_healthy = True
+
+ def _embed(self, text: str) -> np.ndarray:
+ if "beta" in text or "fresh" in text:
+ return np.array([0.0, 1.0], dtype=np.float16)
+ return np.array([1.0, 0.0], dtype=np.float16)
+
+ async def health_check(self, _timeout: float = 2.0) -> bool:
+ """Report the fake embedding service as healthy."""
+ return True
+
+ async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray:
+ """Return a deterministic embedding for a single text."""
+ return self._embed(input_text)
+
+ async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
+ """Attach deterministic embeddings to file chunks."""
+ for chunk_node in nodes:
+ chunk_node.embedding = self._embed(chunk_node.text)
+ return nodes
+
+ async def dump(self) -> None:
+ """Persist no state for the in-memory fake."""
+
+
+def node(path: str) -> FileNode:
+ """Build a minimal file node."""
+ return FileNode(path=path, st_mtime=1.0)
+
+
+def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk:
+ """Build a minimal file chunk."""
+ return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata)
+
+
+async def _seed_unembedded_chunk(store: LocalFileStore, chunk_id: str, path: str, text: str) -> None:
+ """Attach one chunk without a vector, keeping the graph invariant intact."""
+ store.file_chunks[chunk_id] = chunk(chunk_id, path, text)
+ file_node = node(path)
+ file_node.chunk_ids = [chunk_id]
+ await store.file_graph.upsert_nodes([file_node])
+
+
+def _new_faiss_store(name, **kwargs):
+ """Construct a FAISS store when the optional backend is installed."""
+ try:
+ store = FaissLocalFileStore(name=name, embedding_store="", **kwargs)
+ except ImportError:
+ pytest.skip("faiss is not installed")
+ return store
+
+
+def _new_zvec_store(name, **kwargs):
+ """Construct a zvec store with embedding disabled at bind time."""
+ try:
+ store = ZvecLocalFileStore(name=name, embedding_store="", **kwargs)
+ except ImportError:
+ pytest.skip("zvec is not installed")
+ return store
+
+
+async def _settle_reindex(store, timeout=5.0):
+ """Wait until no async reindex is pending or in flight."""
+ deadline = time.monotonic() + timeout
+ while store._reindex_event.is_set() or store._reindex_busy:
+ if time.monotonic() > deadline:
+ raise AssertionError("async reindex did not settle in time")
+ await asyncio.sleep(0.005)
diff --git a/tests/unit/test_auto_dream.py b/tests/unit/test_auto_dream.py
index 8662c1e8..5a6a0be8 100644
--- a/tests/unit/test_auto_dream.py
+++ b/tests/unit/test_auto_dream.py
@@ -30,6 +30,12 @@ from reme.steps.evolve.dream.integrate import DreamIntegrateStep, _snapshot_dige
from reme.steps.evolve.dream.utils import parse_structured_reply, recent_dates, scan_day_files
from reme.steps.file_io.frontmatter_update import FrontmatterUpdateStep
+_MALFORMED_EXTRACT_REPLY = (
+ "units:\n- name: checkout\n bucket: procedure\n"
+ " summary: CI must add `with: submodules: recursive`\n"
+ " paths: [daily/2026-05-28/session.md]\n"
+)
+
def _touch(path: Path, text: str = "x") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -116,9 +122,11 @@ class _SequenceAgent(BaseAgentWrapper):
super().__init__()
self.outcomes = list(outcomes)
self.calls = 0
+ self.requests = []
async def reply(self, _message, **_kwargs):
self.calls += 1
+ self.requests.append((_message, _kwargs))
outcome = self.outcomes.pop(0)
if callable(outcome):
outcome = outcome()
@@ -245,6 +253,25 @@ def test_parse_structured_reply_handles_fenced_yaml_and_scalar_fallback():
assert data["note"].startswith("Extended node")
+def test_extract_parser_preserves_yaml_diagnostics_and_accepts_quoted_colons():
+ """Strict extraction rejects the reported YAML while legacy parsing stays compatible."""
+ assert parse_structured_reply(_MALFORMED_EXTRACT_REPLY) == {}
+ with pytest.raises(ValueError, match="ScannerError") as exc:
+ parse_structured_reply(_MALFORMED_EXTRACT_REPLY, strict=True)
+ assert "line 4" in str(exc.value)
+ assert "submodules: recursive" in str(exc.value)
+ quoted = _MALFORMED_EXTRACT_REPLY.replace(
+ "summary: CI must add `with: submodules: recursive`",
+ 'summary: "CI must add `with: submodules: recursive`"',
+ )
+ assert len(parse_structured_reply(quoted, strict=True)["units"]) == 1
+ assert len(parse_structured_reply(f"```yaml\n{quoted}```", strict=True)["units"]) == 1
+ assert parse_structured_reply("{}", strict=True) == {}
+ for reply in ("", "not an object", "[]"):
+ with pytest.raises(ValueError, match="expected a JSON/YAML object"):
+ parse_structured_reply(reply, strict=True)
+
+
def test_integrate_prompts_require_wikilinks_in_contextual_prose():
"""Every digest bucket rejects standalone relation fields and bare links."""
prompt_path = Path(__file__).parents[2] / "reme" / "steps" / "evolve" / "dream" / "integrate.yaml"
@@ -277,227 +304,392 @@ def test_extract_clean_output_respects_max_units():
assert [unit["name"] for unit in state.units] == [f"unit-{i}" for i in range(5)]
-def test_extract_unusable_receipt_is_a_warning_not_a_failure(tmp_path):
- """Malformed best-effort extraction output does not fail the nightly job."""
+@pytest.mark.parametrize("field", ["units", "memory_units"])
+@pytest.mark.parametrize(
+ ("raw_bucket", "expected_bucket"),
+ [
+ (" procedure ", "procedure"),
+ ("\tpersonal\n", "personal"),
+ (" wiki ", "wiki"),
+ (" unknown ", "wiki"),
+ (" \t\n ", "wiki"),
+ ],
+)
+def test_extract_validation_strips_bucket_whitespace_before_fallback(field, raw_bucket, expected_bucket):
+ """Padded known buckets retain their destination; unknown and blank values still use wiki."""
+ state = DreamState(changed_paths=["daily/a.md"])
+ unit = {"name": "unit", "bucket": raw_bucket, "summary": "memory", "paths": ["daily/a.md"]}
- async def run():
- _touch(tmp_path / "daily" / "2026-05-28" / "session.md")
- step = DreamExtractStep(scan_days=1, app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- step.agent_wrapper = _ReplyAgent()
+ DreamExtractStep().clean_output(state, {field: [unit]})
- with (
- patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}),
- patch("reme.steps.evolve.dream.extract.llm_available", return_value=True),
- ):
- response = await step(
- RuntimeContext(
- date="2026-05-28",
- agent_wrapper=step.agent_wrapper,
- file_catalog=_Catalog(),
- file_store=_FileStore(tmp_path),
- ),
- )
+ assert state.units == [{**unit, "bucket": expected_bucket}]
+
+def test_extract_validation_preserves_legacy_alias_and_unknown_bucket_fallback():
+ """Validation retains the documented bucket fallback and source-path cleanup."""
+ state = DreamState(changed_paths=["daily/a.md"])
+ DreamExtractStep().clean_output(
+ state,
+ {
+ "memory_units": [
+ {
+ "name": " unit ",
+ "bucket": "unknown",
+ "summary": " memory ",
+ "paths": ["daily/a.md", "daily/a.md", "outside.md"],
+ },
+ ],
+ },
+ )
+ assert state.units == [{"name": "unit", "bucket": "wiki", "summary": "memory", "paths": ["daily/a.md"]}]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("job_name", ["auto_dream", "dream_cron"])
+@pytest.mark.parametrize(
+ ("reply", "succeeds"),
+ [
+ (_MALFORMED_EXTRACT_REPLY, False),
+ ("{}", False),
+ ('{"units": null}', False),
+ ('{"units": {}}', False),
+ ('{"units": [42]}', False),
+ ('{"units": [{"name": "missing-fields"}]}', False),
+ (
+ '{"units": [{"name": "x", "summary": " ", "bucket": "wiki", "paths": ["daily/2026-05-28/session.md"]}]}',
+ False,
+ ),
+ ('{"units": [{"name": "x", "summary": "x", "bucket": "wiki", "paths": ["outside.md"]}]}', False),
+ (
+ '{"units": [{"name": "valid", "summary": "x", "bucket": "wiki", '
+ '"paths": ["daily/2026-05-28/session.md"]}, '
+ '{"name": "invalid", "summary": "x", "bucket": "wiki", "paths": []}]}',
+ False,
+ ),
+ ('{"units": []}', True),
+ ("units: []", True),
+ ("memory_units: []", True),
+ ],
+)
+async def test_dream_extraction_failure_preserves_changed_inputs(tmp_path, monkeypatch, job_name, reply, succeeds):
+ """Invalid receipts fail both configured pipelines without checkpointing notes or day indexes."""
+
+ class Catalog(_Catalog):
+ """Expose versions checkpointed by Finish to the next job invocation."""
+
+ async def get_nodes(self, paths=None):
+ return self.upserts
+
+ note = _touch(tmp_path / "daily/2026-05-28/session.md", "CI needs submodules: recursive")
+ _touch(tmp_path / "daily/2026-05-28.md", "day index")
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ catalog, agent = Catalog(), _ReplyAgent({"result": reply})
+ config = resolve_app_config(config="default", log_config=False)["jobs"][job_name]
+ job = BaseJob(name=job_name, steps=config["steps"], app_context=app_context)
+ monkeypatch.setattr("reme.steps.evolve.dream.extract.refresh_day_index", AsyncMock(return_value={}))
+ monkeypatch.setattr("reme.steps.evolve.dream.extract.llm_available", lambda _step: True)
+ await job.start()
+ try:
+ inputs = {
+ "date": "2026-05-28",
+ "scan_days": 1,
+ "agent_wrapper": agent,
+ "file_catalog": catalog,
+ "file_store": _FileStore(tmp_path),
+ }
+ response = await job(**inputs)
dream = response.metadata["dream"]
- assert response.success is True
+ assert response.success is succeeds
+ assert response.answer.startswith("AutoDream completed" if succeeds else "AutoDream failed")
assert dream["units"] == []
- assert dream["failed_paths"] == []
- assert dream["warnings"] == [
- "dream extract skipped unusable agent receipt after retry; expected a units list",
- ]
- assert step.agent_wrapper.calls == 2
+ assert dream["files_changed"] == 2
+ assert dream["failed_paths"] == ([] if succeeds else dream["changed_paths"])
+ assert bool(dream["errors"]) is not succeeds
+ assert set(dream["checkpoint_paths"]) == (set(dream["changed_paths"]) if succeeds else set())
+ assert catalog.dumps == int(succeeds)
+ assert agent.calls == (1 if succeeds else 2)
+ assert note.read_text(encoding="utf-8") == "CI needs submodules: recursive"
- asyncio.run(run())
+ agent.result = {"result": '{"units": []}'}
+ response = await job(**inputs)
+ assert response.success is True
+ assert response.metadata["dream"]["files_changed"] == (0 if succeeds else 2)
+ assert agent.calls == (1 if succeeds else 3)
+ assert catalog.dumps == (2 if succeeds else 1)
+ finally:
+ await job.close()
-def test_extract_retries_one_unusable_receipt(tmp_path):
+@pytest.mark.asyncio
+@pytest.mark.parametrize("language", ["en", "zh"])
+@pytest.mark.parametrize(
+ ("first_reply", "error"),
+ [(_MALFORMED_EXTRACT_REPLY, "ScannerError"), ('{"units": [{"name": "missing-fields"}]}', "ValidationError")],
+)
+async def test_extract_retry_includes_parser_error_and_recovers_json(tmp_path, language, first_reply, error):
+ """Both localized prompts recover code-like strings using JSON and concrete error feedback."""
+ source = "daily/2026-05-28/session.md"
+ _touch(tmp_path / source)
+ unit = {
+ "name": "checkout",
+ "bucket": "procedure",
+ "summary": 'CI needs `submodules: recursive`, "quoted" text, and \\paths.\nKeep dependencies.',
+ "paths": [source],
+ }
+ agent = _SequenceAgent({"result": first_reply}, {"result": json.dumps({"units": [unit]})})
+ step = DreamExtractStep(
+ scan_days=1,
+ language=language,
+ app_context=ApplicationContext(workspace_dir=str(tmp_path)),
+ agent_wrapper=agent,
+ )
+ with (
+ patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}),
+ patch("reme.steps.evolve.dream.extract.llm_available", return_value=True),
+ ):
+ response = await step(
+ RuntimeContext(date="2026-05-28", file_catalog=_Catalog(), file_store=_FileStore(tmp_path)),
+ )
+
+ assert response.success is True
+ assert response.metadata["dream"]["units"] == [unit]
+ assert not response.metadata["dream"]["errors"]
+ first_message, first_kwargs = agent.requests[0]
+ retry_message, retry_kwargs = agent.requests[1]
+ assert retry_message.startswith(first_message)
+ assert error in retry_message
+ if error == "ScannerError":
+ assert "line 4" in retry_message
+ else:
+ assert "Field required" in retry_message
+ assert "key: value" in retry_message
+ assert retry_kwargs["system_prompt"] == first_kwargs["system_prompt"]
+ system_prompt = first_kwargs["system_prompt"]
+ assert "JSON schema" in system_prompt
+ assert "YAML or JSON" not in system_prompt
+ example = json.loads(system_prompt.splitlines()[-1])
+ assert "submodules: recursive" in example["units"][0]["summary"]
+
+
+@pytest.mark.asyncio
+async def test_extract_retries_one_unusable_receipt(tmp_path):
"""A transient malformed extraction receipt gets one bounded retry."""
- async def run():
- _touch(tmp_path / "daily" / "2026-05-28" / "session.md")
- agent = _SequenceAgent({"result": "{}"}, {"result": '{"units": []}'})
- step = DreamExtractStep(
- scan_days=1,
- app_context=ApplicationContext(workspace_dir=str(tmp_path)),
- agent_wrapper=agent,
+ _touch(tmp_path / "daily" / "2026-05-28" / "session.md")
+ agent = _SequenceAgent({"result": "{}"}, {"result": '{"units": []}'})
+ step = DreamExtractStep(
+ scan_days=1,
+ app_context=ApplicationContext(workspace_dir=str(tmp_path)),
+ agent_wrapper=agent,
+ )
+
+ with (
+ patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}),
+ patch("reme.steps.evolve.dream.extract.llm_available", return_value=True),
+ ):
+ response = await step(
+ RuntimeContext(date="2026-05-28", file_catalog=_Catalog(), file_store=_FileStore(tmp_path)),
)
- with (
- patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}),
- patch("reme.steps.evolve.dream.extract.llm_available", return_value=True),
- ):
- response = await step(
- RuntimeContext(date="2026-05-28", file_catalog=_Catalog(), file_store=_FileStore(tmp_path)),
- )
-
- assert response.success is True
- assert response.metadata["dream"]["warnings"] == []
- assert agent.calls == 2
-
- asyncio.run(run())
+ assert response.success is True
+ assert response.metadata["dream"]["warnings"] == []
+ assert agent.calls == 2
-def test_integrate_invalid_receipt_without_file_change_is_skipped(tmp_path):
- """An unusable integration receipt is a skipped unit, not a failed unit."""
+@pytest.mark.parametrize(
+ "receipt",
+ ["{}", "not a receipt", '{"action": "CREATE", "target_path": "digest/wiki/missing.md"}'],
+)
+@pytest.mark.asyncio
+async def test_integrate_invalid_receipt_without_file_change_fails(tmp_path, receipt):
+ """An unusable integration receipt cannot acknowledge unprocessed source files."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- step = DreamIntegrateStep()
- step.agent_wrapper = _ReplyAgent()
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ step = DreamIntegrateStep()
+ step.agent_wrapper = _ReplyAgent({"result": receipt})
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert state.integrate_results == []
- assert len(state.skipped_units) == 1
- assert state.failed_units == []
- assert not state.failed_paths
- assert len(state.warnings) == 1
- assert step.agent_wrapper.calls == 2
-
- asyncio.run(run())
+ assert state.integrate_results == []
+ assert state.skipped_units == []
+ assert len(state.failed_units) == 1
+ assert state.failed_units[0]["name"] == unit["name"]
+ assert state.failed_paths == unit["paths"]
+ assert len(state.errors) == 1
+ assert state.warnings == []
+ assert step.agent_wrapper.calls == 2
-def test_integrate_retries_one_invalid_receipt(tmp_path):
+@pytest.mark.asyncio
+@pytest.mark.parametrize("shared_source", [False, True])
+async def test_integrate_failure_only_blocks_affected_sources(tmp_path, monkeypatch, shared_source):
+ """A failed unit prevents checkpointing all its sources, even one used by a successful unit."""
+ good, bad, target = "daily/good.md", "daily/bad.md", "digest/wiki/good.md"
+ observed = {rel: _touch(tmp_path / rel).stat().st_mtime for rel in (good, bad)}
+ failed_paths = [bad, good] if shared_source else [bad]
+ units = [
+ {"name": "good", "bucket": "wiki", "summary": "good memory", "paths": [good]},
+ {"name": "bad", "bucket": "wiki", "summary": "bad memory", "paths": failed_paths},
+ ]
+
+ def integrate_good():
+ _touch(tmp_path / target, "integrated memory")
+ return {"result": json.dumps({"action": "CREATE", "target_path": target})}
+
+ agent = _SequenceAgent(integrate_good, {"result": "{}"}, {"result": "{}"})
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ state = DreamState(units=units, changed_paths=[good, bad], existing=observed, workspace=str(tmp_path))
+ catalog = _Catalog()
+ context = RuntimeContext(dream=state.model_dump(), file_catalog=catalog, file_store=_FileStore(tmp_path))
+ monkeypatch.setattr("reme.steps.evolve.dream.integrate.llm_available", lambda _step: True)
+
+ response = await DreamIntegrateStep(app_context=app_context, agent_wrapper=agent)(context)
+ assert response.success is False
+ response = await DreamFinishStep(app_context=app_context)(context)
+
+ dream = response.metadata["dream"]
+ assert response.success is False
+ assert len(dream["integrate_results"]) == 1
+ assert len(dream["failed_units"]) == 1
+ assert set(dream["failed_paths"]) == set(failed_paths)
+ assert dream["checkpoint_paths"] == ([] if shared_source else [good])
+ assert {node.path for node in catalog.upserts} == (set() if shared_source else {good})
+ assert (tmp_path / target).read_text(encoding="utf-8") == "integrated memory"
+ assert agent.calls == 3
+
+
+@pytest.mark.asyncio
+async def test_integrate_retries_one_invalid_receipt(tmp_path):
"""A transient invalid integration receipt gets one bounded retry."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- target = _touch(tmp_path / "digest" / "wiki" / "unit.md", "integrated")
- agent = _SequenceAgent(
- {"result": "{}"},
- {
- "result": (
- '{"action": "REFINE", "target_path": "digest/wiki/unit.md", ' '"note": "updated after retry"}'
- ),
- },
- )
- step = DreamIntegrateStep(agent_wrapper=agent)
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ target = _touch(tmp_path / "digest" / "wiki" / "unit.md", "integrated")
+ agent = _SequenceAgent(
+ {"result": "{}"},
+ {
+ "result": ('{"action": "REFINE", "target_path": "digest/wiki/unit.md", ' '"note": "updated after retry"}'),
+ },
+ )
+ step = DreamIntegrateStep(agent_wrapper=agent)
- with patch(
- "reme.steps.evolve.dream.integrate._snapshot_digest",
- wraps=_snapshot_digest,
- ) as snapshot:
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ with patch(
+ "reme.steps.evolve.dream.integrate._snapshot_digest",
+ wraps=_snapshot_digest,
+ ) as snapshot:
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert target.is_file()
- assert state.integrate_results[0]["target_path"] == "digest/wiki/unit.md"
- assert state.skipped_units == []
- assert agent.calls == 2
- assert snapshot.call_count == 3
-
- asyncio.run(run())
+ assert target.is_file()
+ assert state.integrate_results[0]["target_path"] == "digest/wiki/unit.md"
+ assert state.skipped_units == []
+ assert agent.calls == 2
+ assert snapshot.call_count == 3
-def test_integrate_invalid_receipt_recovers_one_changed_digest_file(tmp_path):
+@pytest.mark.asyncio
+async def test_integrate_invalid_receipt_recovers_one_changed_digest_file(tmp_path):
"""The file-native side effect wins when the agent's final receipt is malformed."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- target = tmp_path / "digest" / "wiki" / "unit.md"
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ target = tmp_path / "digest" / "wiki" / "unit.md"
- def write_digest():
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_text("integrated", encoding="utf-8")
+ def write_digest():
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text("integrated", encoding="utf-8")
- step = DreamIntegrateStep()
- step.agent_wrapper = _ReplyAgent(on_reply=write_digest)
+ step = DreamIntegrateStep()
+ step.agent_wrapper = _ReplyAgent(on_reply=write_digest)
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert state.skipped_units == []
- assert state.failed_units == []
- assert state.integrate_results[0]["action"] == "CREATE"
- assert state.integrate_results[0]["target_path"] == "digest/wiki/unit.md"
- assert state.nodes_created == ["digest/wiki/unit.md"]
- assert state.modified_paths == ["digest/wiki/unit.md"]
- assert len(state.warnings) == 1
-
- asyncio.run(run())
+ assert state.skipped_units == []
+ assert state.failed_units == []
+ assert state.integrate_results[0]["action"] == "CREATE"
+ assert state.integrate_results[0]["target_path"] == "digest/wiki/unit.md"
+ assert state.nodes_created == ["digest/wiki/unit.md"]
+ assert state.modified_paths == ["digest/wiki/unit.md"]
+ assert len(state.warnings) == 1
-def test_integrate_recovers_an_update_to_another_bucket(tmp_path):
+@pytest.mark.asyncio
+async def test_integrate_recovers_an_update_to_another_bucket(tmp_path):
"""A malformed receipt can recover an update to an existing cross-bucket node."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- target = tmp_path / "digest" / "procedure" / "unit.md"
- _touch(target, "existing procedure")
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ target = tmp_path / "digest" / "procedure" / "unit.md"
+ _touch(target, "existing procedure")
- def write_digest():
- target.write_text("updated procedure", encoding="utf-8")
+ def write_digest():
+ target.write_text("updated procedure", encoding="utf-8")
- step = DreamIntegrateStep()
- step.agent_wrapper = _ReplyAgent(on_reply=write_digest)
+ step = DreamIntegrateStep()
+ step.agent_wrapper = _ReplyAgent(on_reply=write_digest)
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert state.skipped_units == []
- assert state.integrate_results[0]["action"] == "UPDATED"
- assert state.integrate_results[0]["target_path"] == "digest/procedure/unit.md"
- assert state.nodes_updated == ["digest/procedure/unit.md"]
- assert state.modified_paths == ["digest/procedure/unit.md"]
- assert step.agent_wrapper.calls == 1
-
- asyncio.run(run())
+ assert state.skipped_units == []
+ assert state.integrate_results[0]["action"] == "UPDATED"
+ assert state.integrate_results[0]["target_path"] == "digest/procedure/unit.md"
+ assert state.nodes_updated == ["digest/procedure/unit.md"]
+ assert state.modified_paths == ["digest/procedure/unit.md"]
+ assert step.agent_wrapper.calls == 1
-def test_integrate_does_not_recover_a_create_in_another_bucket(tmp_path):
+@pytest.mark.asyncio
+async def test_integrate_does_not_recover_a_create_in_another_bucket(tmp_path):
"""A malformed receipt cannot recover a cross-bucket create."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- target = tmp_path / "digest" / "procedure" / "unit.md"
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ target = tmp_path / "digest" / "procedure" / "unit.md"
- def write_digest():
- _touch(target, "created procedure")
+ def write_digest():
+ _touch(target, "created procedure")
- step = DreamIntegrateStep(agent_wrapper=_ReplyAgent(on_reply=write_digest))
+ step = DreamIntegrateStep(agent_wrapper=_ReplyAgent(on_reply=write_digest))
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert state.integrate_results == []
- assert len(state.skipped_units) == 1
- assert state.nodes_created == []
- assert state.modified_paths == ["digest/procedure/unit.md"]
-
- asyncio.run(run())
+ assert state.integrate_results == []
+ assert state.skipped_units == []
+ assert len(state.failed_units) == 1
+ assert state.failed_paths == unit["paths"]
+ assert state.errors
+ assert state.nodes_created == []
+ assert state.modified_paths == ["digest/procedure/unit.md"]
-def test_integrate_accepts_cross_bucket_update_receipt(tmp_path):
+@pytest.mark.asyncio
+async def test_integrate_accepts_cross_bucket_update_receipt(tmp_path):
"""An UPDATE receipt may target an existing node in any supported bucket."""
- async def run():
- unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
- state = DreamState(units=[unit])
- _touch(tmp_path / "digest" / "procedure" / "unit.md", "updated procedure")
- agent = _ReplyAgent(
- result={
- "result": (
- '{"action": "REFINE", "target_path": "digest/procedure/unit.md", '
- '"note": "updated existing procedure"}'
- ),
- },
- )
- step = DreamIntegrateStep(agent_wrapper=agent)
+ unit = {"name": "unit", "bucket": "wiki", "summary": "summary", "paths": ["daily/a.md"]}
+ state = DreamState(units=[unit])
+ _touch(tmp_path / "digest" / "procedure" / "unit.md", "updated procedure")
+ agent = _ReplyAgent(
+ result={
+ "result": (
+ '{"action": "REFINE", "target_path": "digest/procedure/unit.md", '
+ '"note": "updated existing procedure"}'
+ ),
+ },
+ )
+ step = DreamIntegrateStep(agent_wrapper=agent)
- with patch(
- "reme.steps.evolve.dream.integrate._snapshot_digest",
- wraps=_snapshot_digest,
- ) as snapshot:
- await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
+ with patch(
+ "reme.steps.evolve.dream.integrate._snapshot_digest",
+ wraps=_snapshot_digest,
+ ) as snapshot:
+ await step._integrate_one(state, unit, 1, tmp_path, "digest") # pylint: disable=protected-access
- assert state.skipped_units == []
- assert state.integrate_results[0]["action"] == "REFINE"
- assert state.nodes_updated == ["digest/procedure/unit.md"]
- assert state.modified_paths == []
- assert snapshot.call_count == 2
-
- asyncio.run(run())
+ assert state.skipped_units == []
+ assert state.integrate_results[0]["action"] == "REFINE"
+ assert state.nodes_updated == ["digest/procedure/unit.md"]
+ assert state.modified_paths == []
+ assert snapshot.call_count == 2
def test_integrate_uses_one_application_wide_lock(tmp_path):
@@ -601,13 +793,13 @@ async def test_integrate_clears_changes_when_skipping_or_missing_llm(tmp_path, m
@pytest.mark.asyncio
@pytest.mark.parametrize("job_name", ["auto_dream", "dream_cron"])
@pytest.mark.parametrize("tag_fails", [False, True])
-@pytest.mark.parametrize("integrate_fails", [False, True])
+@pytest.mark.parametrize("integration_failure", [None, "exception", "invalid_receipt"])
async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
tmp_path,
monkeypatch,
job_name,
tag_fails,
- integrate_fails,
+ integration_failure,
):
"""Both configured pipelines tag durable outputs without changing dream success."""
app_context = ApplicationContext(workspace_dir=str(tmp_path))
@@ -637,8 +829,10 @@ async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
for path in targets:
previous = (tmp_path / path).read_text(encoding="utf-8") if (tmp_path / path).exists() else ""
_touch(tmp_path / path, previous + "ReMe evidence\n")
- if integrate_fails:
+ if integration_failure == "exception":
raise RuntimeError("integration unavailable")
+ if integration_failure == "invalid_receipt":
+ return {"result": "{}"}
return {"result": json.dumps({"action": "CREATE", "target_path": targets[0]})}
agent = _ReplyAgent()
@@ -660,8 +854,9 @@ async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
finally:
await job.close()
+ integrate_fails = bool(integration_failure)
assert response.success is not integrate_fails
- assert response.answer.startswith("AutoDream completed")
+ assert response.answer.startswith("AutoDream failed" if integrate_fails else "AutoDream completed")
assert response.metadata["modified"] is True
dream, tagging = response.metadata["dream"], response.metadata["auto_tag"]
assert (source in dream["checkpoint_paths"]) is not integrate_fails
@@ -678,174 +873,162 @@ async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
assert (tmp_path / source).read_text(encoding="utf-8") == "Source about ReMe"
-def test_extract_without_llm_marks_changed_paths_failed(tmp_path):
+@pytest.mark.asyncio
+async def test_extract_without_llm_marks_changed_paths_failed(tmp_path):
"""A missing LLM must not let finish checkpoint unprocessed source files."""
- async def run():
- note = _touch(tmp_path / "daily" / "2026-05-28" / "session.md")
- step = DreamExtractStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
+ note = _touch(tmp_path / "daily" / "2026-05-28" / "session.md")
+ step = DreamExtractStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- response = await step(
- RuntimeContext(date="2026-05-28", file_catalog=_Catalog(), file_store=_FileStore(tmp_path)),
- )
+ response = await step(
+ RuntimeContext(date="2026-05-28", file_catalog=_Catalog(), file_store=_FileStore(tmp_path)),
+ )
- dream = response.metadata["dream"]
- assert response.success is False
- assert note.relative_to(tmp_path).as_posix() in dream["changed_paths"]
- assert dream["failed_paths"] == dream["changed_paths"]
-
- asyncio.run(run())
+ dream = response.metadata["dream"]
+ assert response.success is False
+ assert note.relative_to(tmp_path).as_posix() in dream["changed_paths"]
+ assert dream["failed_paths"] == dream["changed_paths"]
-def test_finish_does_not_checkpoint_failed_changed_paths():
+@pytest.mark.asyncio
+async def test_finish_does_not_checkpoint_failed_changed_paths():
"""Finish does not checkpoint failed changed paths."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp:
- workspace = Path(tmp)
- ok = _touch(workspace / "daily" / "2026-05-28" / "ok.md")
- failed = _touch(workspace / "daily" / "2026-05-28" / "failed.md")
- day_index = _touch(workspace / "daily" / "2026-05-28.md")
- state = DreamState(
- date="2026-05-28",
- dates=["2026-05-26", "2026-05-27", "2026-05-28"],
- workspace=str(workspace),
- daily_dir="daily",
- changed_paths=[ok.relative_to(workspace).as_posix(), failed.relative_to(workspace).as_posix()],
- existing={
- path.relative_to(workspace).as_posix(): path.stat().st_mtime for path in (ok, failed, day_index)
+ with tempfile.TemporaryDirectory() as tmp:
+ workspace = Path(tmp)
+ ok = _touch(workspace / "daily" / "2026-05-28" / "ok.md")
+ failed = _touch(workspace / "daily" / "2026-05-28" / "failed.md")
+ day_index = _touch(workspace / "daily" / "2026-05-28.md")
+ state = DreamState(
+ date="2026-05-28",
+ dates=["2026-05-26", "2026-05-27", "2026-05-28"],
+ workspace=str(workspace),
+ daily_dir="daily",
+ changed_paths=[ok.relative_to(workspace).as_posix(), failed.relative_to(workspace).as_posix()],
+ existing={path.relative_to(workspace).as_posix(): path.stat().st_mtime for path in (ok, failed, day_index)},
+ failed_paths=[failed.relative_to(workspace).as_posix()],
+ modified_paths=["digest/procedure/example.md"],
+ integrate_results=[
+ {
+ "action": "CREATE",
+ "target_path": "digest/procedure/example.md",
+ "note": "Created a concise procedure node.",
},
- failed_paths=[failed.relative_to(workspace).as_posix()],
- modified_paths=["digest/procedure/example.md"],
- integrate_results=[
- {
- "action": "CREATE",
- "target_path": "digest/procedure/example.md",
- "note": "Created a concise procedure node.",
- },
- ],
- )
- step, catalog = DreamFinishStep(), _Catalog()
- resp = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
+ ],
+ )
+ step, catalog = DreamFinishStep(), _Catalog()
+ resp = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
- upserted = [n.path for n in catalog.upserts]
- assert resp.success is True
- assert resp.answer.startswith("AutoDream completed\n\n")
- assert "action:" not in resp.answer
- assert "topics:" not in resp.answer
- assert "Changes:" in resp.answer
- assert "- [digest/procedure/example.md][CREATE]: Created a concise procedure node." in resp.answer
- assert ok.relative_to(workspace).as_posix() in upserted
- assert failed.relative_to(workspace).as_posix() not in upserted
- assert day_index.relative_to(workspace).as_posix() in upserted
- assert catalog.dumps == 1
- assert resp.metadata["modified"] is True
-
- asyncio.run(run())
+ upserted = [n.path for n in catalog.upserts]
+ assert resp.success is True
+ assert resp.answer.startswith("AutoDream completed\n\n")
+ assert "action:" not in resp.answer
+ assert "topics:" not in resp.answer
+ assert "Changes:" in resp.answer
+ assert "- [digest/procedure/example.md][CREATE]: Created a concise procedure node." in resp.answer
+ assert ok.relative_to(workspace).as_posix() in upserted
+ assert failed.relative_to(workspace).as_posix() not in upserted
+ assert day_index.relative_to(workspace).as_posix() in upserted
+ assert catalog.dumps == 1
+ assert resp.metadata["modified"] is True
-def test_finish_does_not_readd_a_failed_day_index(tmp_path):
+@pytest.mark.asyncio
+async def test_finish_does_not_readd_a_failed_day_index(tmp_path):
"""The generated day-index list cannot bypass the failed-path filter."""
- async def run():
- day_index = _touch(tmp_path / "daily" / "2026-05-28.md")
- rel_path = day_index.relative_to(tmp_path).as_posix()
- state = DreamState(
- date="2026-05-28",
- dates=["2026-05-28"],
- workspace=str(tmp_path),
- daily_dir="daily",
- changed_paths=[rel_path],
- existing={rel_path: day_index.stat().st_mtime},
- failed_paths=[rel_path],
- errors=["extract unavailable"],
- )
- step, catalog = DreamFinishStep(), _Catalog()
+ day_index = _touch(tmp_path / "daily" / "2026-05-28.md")
+ rel_path = day_index.relative_to(tmp_path).as_posix()
+ state = DreamState(
+ date="2026-05-28",
+ dates=["2026-05-28"],
+ workspace=str(tmp_path),
+ daily_dir="daily",
+ changed_paths=[rel_path],
+ existing={rel_path: day_index.stat().st_mtime},
+ failed_paths=[rel_path],
+ errors=["extract unavailable"],
+ )
+ step, catalog = DreamFinishStep(), _Catalog()
- response = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
+ response = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
- assert response.success is False
- assert not catalog.upserts
- assert response.metadata["dream"]["checkpoint_paths"] == []
- assert response.metadata["modified"] is False
-
- asyncio.run(run())
+ assert response.success is False
+ assert not catalog.upserts
+ assert response.metadata["dream"]["checkpoint_paths"] == []
+ assert response.metadata["modified"] is False
-def test_finish_keeps_skipped_agent_output_successful(tmp_path):
+@pytest.mark.asyncio
+async def test_finish_keeps_skipped_agent_output_successful(tmp_path):
"""Best-effort agent skips remain successful and checkpointed after their bounded retry."""
- async def run():
- rel_path = "daily/2026-05-28/source.md"
- source = _touch(tmp_path / rel_path)
- state = DreamState(
- date="2026-05-28",
- dates=["2026-05-28"],
- workspace=str(tmp_path),
- daily_dir="daily",
- changed_paths=[rel_path],
- existing={rel_path: source.stat().st_mtime},
- skipped_units=[{"name": "unit", "reason": "unusable receipt"}],
- warnings=["unit 'unit' skipped: unusable agent receipt"],
- )
- step, catalog = DreamFinishStep(), _Catalog()
+ rel_path = "daily/2026-05-28/source.md"
+ source = _touch(tmp_path / rel_path)
+ state = DreamState(
+ date="2026-05-28",
+ dates=["2026-05-28"],
+ workspace=str(tmp_path),
+ daily_dir="daily",
+ changed_paths=[rel_path],
+ existing={rel_path: source.stat().st_mtime},
+ skipped_units=[{"name": "unit", "reason": "unusable receipt"}],
+ warnings=["unit 'unit' skipped: unusable agent receipt"],
+ )
+ step, catalog = DreamFinishStep(), _Catalog()
- response = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
+ response = await step(RuntimeContext(dream=state.model_dump(), file_catalog=catalog))
- assert response.success is True
- assert response.answer.startswith("AutoDream completed with warnings\n\n")
- assert "- Integrated: 0 ok, 1 skipped, 0 failed" in response.answer
- assert response.metadata["dream"]["checkpoint_paths"] == [rel_path]
-
- asyncio.run(run())
+ assert response.success is True
+ assert response.answer.startswith("AutoDream completed with warnings\n\n")
+ assert "- Integrated: 0 ok, 1 skipped, 0 failed" in response.answer
+ assert response.metadata["dream"]["checkpoint_paths"] == [rel_path]
@pytest.mark.parametrize("stage", ["extract", "integrate"])
-def test_dream_initializes_started_provider_before_availability_check(tmp_path, stage):
+@pytest.mark.asyncio
+async def test_dream_initializes_started_provider_before_availability_check(tmp_path, stage):
"""Dream's real availability check must initialize a started lazy provider."""
- async def run():
- context = ApplicationContext(workspace_dir=str(tmp_path))
- credential_cls = Mock()
- llm = BaseAsLLM(app_context=context, model="test")
- llm.credential_cls = credential_cls
- source = "daily/2026-05-28/session.md"
- _touch(tmp_path / source, "memory source")
- unit = {"name": "memory", "bucket": "wiki", "summary": "memory summary", "paths": [source]}
- target = "digest/wiki/memory.md"
- if stage == "extract":
- agent = _ReplyAgent({"result": json.dumps({"units": [unit]})})
- step = DreamExtractStep(app_context=context, scan_days=1)
- inputs = {"date": "2026-05-28"}
- else:
- agent = _ReplyAgent(
- {"result": json.dumps({"action": "CREATE", "target_path": target, "note": "created"})},
- on_reply=lambda: _touch(tmp_path / target, "# Memory\n\nmemory summary\n"),
+ context = ApplicationContext(workspace_dir=str(tmp_path))
+ credential_cls = Mock()
+ llm = BaseAsLLM(app_context=context, model="test")
+ llm.credential_cls = credential_cls
+ source = "daily/2026-05-28/session.md"
+ _touch(tmp_path / source, "memory source")
+ unit = {"name": "memory", "bucket": "wiki", "summary": "memory summary", "paths": [source]}
+ target = "digest/wiki/memory.md"
+ if stage == "extract":
+ agent = _ReplyAgent({"result": json.dumps({"units": [unit]})})
+ step = DreamExtractStep(app_context=context, scan_days=1)
+ inputs = {"date": "2026-05-28"}
+ else:
+ agent = _ReplyAgent(
+ {"result": json.dumps({"action": "CREATE", "target_path": target, "note": "created"})},
+ on_reply=lambda: _touch(tmp_path / target, "# Memory\n\nmemory summary\n"),
+ )
+ step = DreamIntegrateStep(app_context=context)
+ inputs = {"dream": {"units": [unit], "workspace": str(tmp_path)}}
+ context.components[ComponentEnum.AS_LLM] = {"default": llm}
+ context.components[ComponentEnum.AGENT_WRAPPER] = {"default": agent}
+ await llm.start()
+ await agent.start()
+ try:
+ credential_cls.assert_not_called()
+ with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
+ response = await step(
+ RuntimeContext(file_catalog=_Catalog(), file_store=_FileStore(tmp_path), **inputs),
)
- step = DreamIntegrateStep(app_context=context)
- inputs = {"dream": {"units": [unit], "workspace": str(tmp_path)}}
- context.components[ComponentEnum.AS_LLM] = {"default": llm}
- context.components[ComponentEnum.AGENT_WRAPPER] = {"default": agent}
- await llm.start()
- await agent.start()
- try:
- credential_cls.assert_not_called()
- with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
- response = await step(
- RuntimeContext(file_catalog=_Catalog(), file_store=_FileStore(tmp_path), **inputs),
- )
- assert response.success, response.answer
- assert agent.calls == 1
- credential_cls.assert_called_once_with()
- dream = response.metadata["dream"]
- assert dream["errors"] == []
- assert dream["failed_paths"] == []
- if stage == "extract":
- assert dream["units"][0]["name"] == "memory"
- else:
- assert dream["integrate_results"][0]["target_path"] == target
- finally:
- await agent.close()
- await llm.close()
-
- asyncio.run(run())
+ assert response.success, response.answer
+ assert agent.calls == 1
+ credential_cls.assert_called_once_with()
+ dream = response.metadata["dream"]
+ assert dream["errors"] == []
+ assert dream["failed_paths"] == []
+ if stage == "extract":
+ assert dream["units"][0]["name"] == "memory"
+ else:
+ assert dream["integrate_results"][0]["target_path"] == target
+ finally:
+ await agent.close()
+ await llm.close()
diff --git a/tests/unit/test_auto_memory_idempotency.py b/tests/unit/test_auto_memory_idempotency.py
new file mode 100644
index 00000000..73bc1b5d
--- /dev/null
+++ b/tests/unit/test_auto_memory_idempotency.py
@@ -0,0 +1,148 @@
+"""Completed automatic-memory requests can be replayed after a client timeout."""
+
+# pylint: disable=protected-access,missing-function-docstring
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from agentscope.message import Msg
+
+from reme.components.runtime_context import RuntimeContext
+from reme.steps.evolve.auto_memory import AutoMemoryStep
+
+
+def _step(tmp_path, request_id="batch-1", **kwargs):
+ step = AutoMemoryStep()
+ step.file_store = SimpleNamespace(workspace_path=tmp_path)
+ step.context = RuntimeContext(
+ request_id=request_id,
+ session_id="session-1",
+ messages=[{"id": "m1", "content": "fact"}],
+ date="2026-10-21",
+ **kwargs,
+ )
+ return step
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("first_is_msg", [False, True])
+@pytest.mark.parametrize("replay_is_msg", [False, True])
+async def test_message_objects_and_dicts_share_a_stable_request_fingerprint(tmp_path, first_is_msg, replay_is_msg):
+ message = Msg.model_validate(
+ {
+ "id": "stable-message",
+ "name": "user",
+ "role": "user",
+ "content": [{"type": "text", "text": "fact"}],
+ "created_at": "2026-10-21T12:00:00+08:00",
+ },
+ )
+ original = message.model_dump(mode="json")
+ first = _step(tmp_path)
+ first.context["messages"] = [message if first_is_msg else original]
+
+ async def execute():
+ first.context.response.answer = "recorded"
+
+ first._execute = execute
+ await first.execute()
+ assert first.context.response.success
+ assert message.model_dump(mode="json") == original
+ replay = _step(tmp_path)
+ replay.context["messages"] = [Msg.model_validate(original) if replay_is_msg else original]
+
+ async def unexpected():
+ pytest.fail("replaying a supported message form must not invoke the agent")
+
+ replay._execute = unexpected
+ await replay.execute()
+ assert replay.context.response == first.context.response
+
+
+@pytest.mark.asyncio
+async def test_completed_request_survives_restart_without_repeating_agent(tmp_path):
+ first = _step(tmp_path)
+
+ async def execute():
+ first.context.response.answer = "recorded"
+ first.context.response.metadata = {"path": "daily/note.md"}
+ first.context["changes"] = [{"change": "modified", "path": "daily/note.md"}]
+
+ first._execute = execute
+ await first.execute()
+ replay = _step(tmp_path)
+
+ async def unexpected():
+ pytest.fail("completed request must not invoke the memory agent")
+
+ replay._execute = unexpected
+ await replay.execute()
+ assert replay.context.response == first.context.response
+ assert replay.context["changes"] == first.context["changes"]
+
+
+@pytest.mark.asyncio
+async def test_concurrent_duplicate_waits_for_the_original_request(tmp_path):
+ first, duplicate = _step(tmp_path), _step(tmp_path)
+ started, release = asyncio.Event(), asyncio.Event()
+ calls = []
+
+ async def execute():
+ calls.append("agent")
+ started.set()
+ await release.wait()
+
+ first._execute = duplicate._execute = execute
+ original = asyncio.create_task(first.execute())
+ await started.wait()
+ retry = asyncio.create_task(duplicate.execute())
+ await asyncio.sleep(0)
+ assert calls == ["agent"]
+ release.set()
+ await asyncio.wait_for(asyncio.gather(original, retry), 2)
+ assert calls == ["agent"]
+
+
+@pytest.mark.asyncio
+async def test_failed_request_can_retry_but_reusing_a_completed_id_for_other_input_fails(tmp_path):
+ step = _step(tmp_path)
+
+ async def fail():
+ step.context.response.success = False
+
+ step._execute = fail
+ await step.execute()
+ assert not list(tmp_path.rglob("*.json"))
+
+ retry = _step(tmp_path)
+
+ async def succeed():
+ return None
+
+ retry._execute = succeed
+ await retry.execute()
+ assert retry.context.response.success
+ conflicting = _step(tmp_path)
+ conflicting.context["messages"] = [{"id": "m2", "content": "different fact"}]
+ await conflicting.execute()
+ assert not conflicting.context.response.success
+ assert "different input" in conflicting.context.response.answer
+
+
+@pytest.mark.asyncio
+async def test_receipts_obey_workspace_permissions(tmp_path):
+ step = _step(tmp_path, _allowed_paths=["daily"])
+ with pytest.raises(PermissionError):
+ await step.execute()
+ assert not list(tmp_path.rglob("*.json"))
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("request_id", ["", "x" * 129, 123])
+async def test_rejects_invalid_request_id_before_running_agent(tmp_path, request_id):
+ step = _step(tmp_path, request_id)
+ await step.execute()
+ assert not step.context.response.success
+ assert "request_id" in step.context.response.answer
+ assert not list(tmp_path.iterdir())
diff --git a/tests/unit/test_background_steps.py b/tests/unit/test_background_steps.py
index b70acd40..0f94f479 100644
--- a/tests/unit/test_background_steps.py
+++ b/tests/unit/test_background_steps.py
@@ -10,6 +10,7 @@ downstream ``update_index_step`` to consume; tests assert against that key.
# pylint: disable=protected-access,too-many-lines
+from contextlib import chdir as temp_chdir
import asyncio
import datetime
import os
@@ -60,22 +61,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def write_file(path: Path, content: str = "x") -> Path:
"""Create parent dirs and write `content` to `path`; return the path."""
path.parent.mkdir(parents=True, exist_ok=True)
@@ -153,7 +138,6 @@ def test_build_watch_rules_basic():
assert rules[0].path == workspace / "daily"
assert rules[0].suffixes == ["md"]
assert rules[1].path == workspace / "digest"
- print("✓ test_build_watch_rules_basic passed")
def test_build_watch_rules_multiple_suffixes():
@@ -172,7 +156,6 @@ def test_build_watch_rules_multiple_suffixes():
assert len(rules) == 2
assert rules[0].suffixes == ["md", "jsonl"]
assert rules[1].suffixes == ["md", "jsonl"]
- print("✓ test_build_watch_rules_multiple_suffixes passed")
def test_build_watch_rules_fallback_literal():
@@ -181,7 +164,6 @@ def test_build_watch_rules_fallback_literal():
workspace = Path("/fake/workspace")
rules = build_watch_rules(app_config, workspace, watch_dirs=["custom_dir"], watch_suffixes=["txt"])
assert rules[0].path == workspace / "custom_dir"
- print("✓ test_build_watch_rules_fallback_literal passed")
def test_match_file_suffix():
@@ -191,7 +173,6 @@ def test_match_file_suffix():
assert match_file("/workspace/daily/sub/note.md", rules)
assert not match_file("/workspace/daily/file.txt", rules)
assert not match_file("/workspace/other/file.md", rules)
- print("✓ test_match_file_suffix passed")
def test_match_file_suffix_is_case_insensitive():
@@ -208,7 +189,6 @@ def test_match_file_no_suffix_filter():
assert match_file("/workspace/resource/anything.xyz", rules)
assert match_file("/workspace/resource/sub/deep.pdf", rules)
assert not match_file("/workspace/other/file.md", rules)
- print("✓ test_match_file_no_suffix_filter passed")
def test_collect_existing_filters():
@@ -232,7 +212,6 @@ def test_collect_existing_filters():
assert str((daily / "ignore.txt").absolute()) not in paths
assert str((resource / "data.json").absolute()) in paths
assert str((resource / "binary.png").absolute()) not in paths
- print("✓ test_collect_existing_filters passed")
def test_collect_existing_matches_uppercase_suffixes():
@@ -252,32 +231,29 @@ def test_collect_existing_matches_uppercase_suffixes():
# ---------------------------------------------------------------------------
-def test_clear_and_scan_flow_includes_jsonl_when_configured():
+@pytest.mark.asyncio
+async def test_clear_and_scan_flow_includes_jsonl_when_configured():
"""The legacy clear-and-scan primitives accept configured JSONL inputs."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- write_file(cwd / "daily" / "note.md", "alpha")
- write_file(cwd / "resource" / "events.jsonl", '{"a": 1}\n')
- write_file(cwd / "resource" / "ignore.txt", "skip")
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ write_file(cwd / "daily" / "note.md", "alpha")
+ write_file(cwd / "resource" / "events.jsonl", '{"a": 1}\n')
+ write_file(cwd / "resource" / "ignore.txt", "skip")
- fs = LocalFileStore(name="test_store", embedding_store="")
- await fs.start()
- try:
- clear_step = ClearStoreStep(file_store=fs, app_context=_make_app_context(cwd))
- scan_step = InitChangesStep(store="file_store", file_store=fs, app_context=_make_app_context(cwd))
- ctx = RuntimeContext(watch_dirs=["daily_dir", "resource_dir"], watch_suffixes=["md", "jsonl"])
- await clear_step(ctx)
- resp = await scan_step(ctx)
- paths = {Path(item["path"]).name for item in ctx["changes"]}
- assert resp.metadata["counts"] == {"added": 2, "modified": 0, "deleted": 0}
- assert paths == {"note.md", "events.jsonl"}
- finally:
- await fs.close()
- print("✓ test_clear_and_scan_defaults_include_jsonl passed")
-
- asyncio.run(run())
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ await fs.start()
+ try:
+ clear_step = ClearStoreStep(file_store=fs, app_context=_make_app_context(cwd))
+ scan_step = InitChangesStep(store="file_store", file_store=fs, app_context=_make_app_context(cwd))
+ ctx = RuntimeContext(watch_dirs=["daily_dir", "resource_dir"], watch_suffixes=["md", "jsonl"])
+ await clear_step(ctx)
+ resp = await scan_step(ctx)
+ paths = {Path(item["path"]).name for item in ctx["changes"]}
+ assert resp.metadata["counts"] == {"added": 2, "modified": 0, "deleted": 0}
+ assert paths == {"note.md", "events.jsonl"}
+ finally:
+ await fs.close()
async def _make_scan_step(workspace_path: Path, watch_dirs=None, watch_suffixes=None, recursive=True):
@@ -305,151 +281,133 @@ async def _teardown(fs: LocalFileStore, chunker: DefaultFileChunker) -> None:
await fs.close()
-def test_scan_changes_initial_all_added():
+@pytest.mark.asyncio
+async def test_scan_changes_initial_all_added():
"""First run on a fresh store emits 'added' for every existing file."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- write_file(cwd / "daily" / "a.md", "alpha")
- write_file(cwd / "daily" / "b.md", "beta")
- (cwd / "digest").mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ write_file(cwd / "daily" / "a.md", "alpha")
+ write_file(cwd / "daily" / "b.md", "beta")
+ (cwd / "digest").mkdir(parents=True, exist_ok=True)
- step, ctx, fs, chunker = await _make_scan_step(cwd)
- try:
- resp = await step(ctx)
- counts = resp.metadata["counts"]
- assert counts == {"added": 2, "modified": 0, "deleted": 0}
- assert len(ctx["changes"]) == 2
- finally:
- await _teardown(fs, chunker)
- print("✓ test_scan_changes_initial_all_added passed")
-
- asyncio.run(run())
+ step, ctx, fs, chunker = await _make_scan_step(cwd)
+ try:
+ resp = await step(ctx)
+ counts = resp.metadata["counts"]
+ assert counts == {"added": 2, "modified": 0, "deleted": 0}
+ assert len(ctx["changes"]) == 2
+ finally:
+ await _teardown(fs, chunker)
-def test_scan_changes_no_changes():
+@pytest.mark.asyncio
+async def test_scan_changes_no_changes():
"""Second run over an unchanged store reports zero counts."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- a = write_file(cwd / "daily" / "a.md", "alpha")
- (cwd / "digest").mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ a = write_file(cwd / "daily" / "a.md", "alpha")
+ (cwd / "digest").mkdir(parents=True, exist_ok=True)
- step, ctx, fs, chunker = await _make_scan_step(cwd)
- try:
- node, chunks = await chunker.chunk(a)
- await fs.upsert([(node, chunks)])
- resp = await step(ctx)
- assert resp.metadata["counts"] == {"added": 0, "modified": 0, "deleted": 0}
- assert ctx["changes"] == []
- finally:
- await _teardown(fs, chunker)
- print("✓ test_scan_changes_no_changes passed")
-
- asyncio.run(run())
+ step, ctx, fs, chunker = await _make_scan_step(cwd)
+ try:
+ node, chunks = await chunker.chunk(a)
+ await fs.upsert([(node, chunks)])
+ resp = await step(ctx)
+ assert resp.metadata["counts"] == {"added": 0, "modified": 0, "deleted": 0}
+ assert ctx["changes"] == []
+ finally:
+ await _teardown(fs, chunker)
-def test_scan_changes_detect_modify_delete():
+@pytest.mark.asyncio
+async def test_scan_changes_detect_modify_delete():
"""Second pass distinguishes added/modified/deleted."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- a = write_file(cwd / "daily" / "a.md", "alpha")
- b = write_file(cwd / "daily" / "b.md", "beta")
- (cwd / "digest").mkdir(parents=True, exist_ok=True)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ a = write_file(cwd / "daily" / "a.md", "alpha")
+ b = write_file(cwd / "daily" / "b.md", "beta")
+ (cwd / "digest").mkdir(parents=True, exist_ok=True)
- step, ctx, fs, chunker = await _make_scan_step(cwd)
- try:
- for p in (a, b):
- node, chunks = await chunker.chunk(p)
- await fs.upsert([(node, chunks)])
- a.write_text("alpha-v2", encoding="utf-8")
- os.utime(a, (9_999_999_999, 9_999_999_999))
- b.unlink()
- write_file(cwd / "daily" / "c.md", "gamma")
+ step, ctx, fs, chunker = await _make_scan_step(cwd)
+ try:
+ for p in (a, b):
+ node, chunks = await chunker.chunk(p)
+ await fs.upsert([(node, chunks)])
+ a.write_text("alpha-v2", encoding="utf-8")
+ os.utime(a, (9_999_999_999, 9_999_999_999))
+ b.unlink()
+ write_file(cwd / "daily" / "c.md", "gamma")
- resp = await step(ctx)
- counts = resp.metadata["counts"]
- assert counts == {"added": 1, "modified": 1, "deleted": 1}
- finally:
- await _teardown(fs, chunker)
- print("✓ test_scan_changes_detect_modify_delete passed")
-
- asyncio.run(run())
+ resp = await step(ctx)
+ counts = resp.metadata["counts"]
+ assert counts == {"added": 1, "modified": 1, "deleted": 1}
+ finally:
+ await _teardown(fs, chunker)
-def test_scan_changes_missing_dir_skipped():
+@pytest.mark.asyncio
+async def test_scan_changes_missing_dir_skipped():
"""Non-existent watch_dirs entries are dropped silently."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- (cwd / "daily").mkdir()
- # digest dir missing
- step, ctx, fs, chunker = await _make_scan_step(cwd)
- try:
- resp = await step(ctx)
- assert resp.metadata["counts"] == {"added": 0, "modified": 0, "deleted": 0}
- finally:
- await _teardown(fs, chunker)
- print("✓ test_scan_changes_missing_dir_skipped passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ (cwd / "daily").mkdir()
+ # digest dir missing
+ step, ctx, fs, chunker = await _make_scan_step(cwd)
+ try:
+ resp = await step(ctx)
+ assert resp.metadata["counts"] == {"added": 0, "modified": 0, "deleted": 0}
+ finally:
+ await _teardown(fs, chunker)
-def test_scan_changes_resource_dir():
+@pytest.mark.asyncio
+async def test_scan_changes_resource_dir():
"""Scanning resource_dir with multiple suffixes works."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- resource = cwd / "resource"
- write_file(resource / "data.json", "{}")
- write_file(resource / "note.md", "# Note")
- write_file(resource / "image.png", "binary")
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ resource = cwd / "resource"
+ write_file(resource / "data.json", "{}")
+ write_file(resource / "note.md", "# Note")
+ write_file(resource / "image.png", "binary")
- step, ctx, fs, chunker = await _make_scan_step(
- cwd,
- watch_dirs=["resource_dir"],
- watch_suffixes=["md", "json"],
- )
- try:
- resp = await step(ctx)
- assert resp.metadata["counts"]["added"] == 2
- finally:
- await _teardown(fs, chunker)
- print("✓ test_scan_changes_resource_dir passed")
-
- asyncio.run(run())
+ step, ctx, fs, chunker = await _make_scan_step(
+ cwd,
+ watch_dirs=["resource_dir"],
+ watch_suffixes=["md", "json"],
+ )
+ try:
+ resp = await step(ctx)
+ assert resp.metadata["counts"]["added"] == 2
+ finally:
+ await _teardown(fs, chunker)
-def test_init_changes_named_file_catalog_monitor():
+@pytest.mark.asyncio
+async def test_init_changes_named_file_catalog_monitor():
"""monitor_type/monitor_name selects the requested file_catalog component."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- write_file(cwd / "resource" / "2026-01-01" / "a.md", "alpha")
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ write_file(cwd / "resource" / "2026-01-01" / "a.md", "alpha")
- catalog = LocalFileCatalog(name="resource")
- await catalog.start()
- try:
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {ComponentEnum.FILE_CATALOG: {"resource": catalog}}
- step = InitChangesStep(monitor_type="file_catalog", monitor_name="resource", app_context=app_ctx)
- ctx = RuntimeContext(watch_dirs=["resource_dir"], watch_suffixes=["md"])
- resp = await step(ctx)
+ catalog = LocalFileCatalog(name="resource")
+ await catalog.start()
+ try:
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {ComponentEnum.FILE_CATALOG: {"resource": catalog}}
+ step = InitChangesStep(monitor_type="file_catalog", monitor_name="resource", app_context=app_ctx)
+ ctx = RuntimeContext(watch_dirs=["resource_dir"], watch_suffixes=["md"])
+ resp = await step(ctx)
- assert resp.metadata["counts"] == {"added": 1, "modified": 0, "deleted": 0}
- assert ctx["changes"][0]["change"] == "added"
- finally:
- await catalog.close()
- print("✓ test_init_changes_named_file_catalog_monitor passed")
-
- asyncio.run(run())
+ assert resp.metadata["counts"] == {"added": 1, "modified": 0, "deleted": 0}
+ assert ctx["changes"][0]["change"] == "added"
+ finally:
+ await catalog.close()
def test_bucket_changes_coalesces_by_final_file_state():
@@ -466,177 +424,165 @@ def test_bucket_changes_coalesces_by_final_file_state():
assert buckets[Change.modified] == [str(p)]
assert buckets[Change.added] == []
assert buckets[Change.deleted] == []
- print("✓ test_bucket_changes_coalesces_by_final_file_state passed")
-def test_update_catalog_relative_path_uses_workspace():
+@pytest.mark.asyncio
+async def test_update_catalog_relative_path_uses_workspace():
"""update_catalog_step resolves workspace-relative change paths against workspace_path."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- write_file(cwd / "daily" / "a.md", "alpha")
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ write_file(cwd / "daily" / "a.md", "alpha")
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- try:
- step = UpdateCatalogStep(file_catalog=catalog, app_context=_make_app_context(cwd))
- ctx = RuntimeContext(changes=[{"change": "added", "path": "daily/a.md"}])
- resp = await step(ctx)
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ try:
+ step = UpdateCatalogStep(file_catalog=catalog, app_context=_make_app_context(cwd))
+ ctx = RuntimeContext(changes=[{"change": "added", "path": "daily/a.md"}])
+ resp = await step(ctx)
- assert resp.success is True
- nodes = await catalog.get_nodes()
- assert [n.path for n in nodes] == ["daily/a.md"]
- finally:
- await catalog.close()
- print("✓ test_update_catalog_relative_path_uses_workspace passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ nodes = await catalog.get_nodes()
+ assert [n.path for n in nodes] == ["daily/a.md"]
+ finally:
+ await catalog.close()
-def test_update_catalog_upserts_in_batches_of_at_most_100_files():
+@pytest.mark.asyncio
+async def test_update_catalog_upserts_in_batches_of_at_most_100_files():
"""Large change sets are committed incrementally instead of retained as one list."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- paths = [write_file(cwd / "daily" / f"{index:03d}.md", "x") for index in range(205)]
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- batch_sizes = []
- original_upsert = catalog.upsert
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ paths = [write_file(cwd / "daily" / f"{index:03d}.md", "x") for index in range(205)]
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ batch_sizes = []
+ original_upsert = catalog.upsert
- async def record_upsert(nodes):
- batch_sizes.append(len(nodes))
- await original_upsert(nodes)
+ async def record_upsert(nodes):
+ batch_sizes.append(len(nodes))
+ await original_upsert(nodes)
- try:
- step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
- changes = [{"change": "added", "path": str(path)} for path in paths]
- available = MagicMock(available=8 * 1024 * 1024 * 1024)
- with (
- patch.object(catalog, "upsert", side_effect=record_upsert),
- patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
- ):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
+ changes = [{"change": "added", "path": str(path)} for path in paths]
+ available = MagicMock(available=8 * 1024 * 1024 * 1024)
+ with (
+ patch.object(catalog, "upsert", side_effect=record_upsert),
+ patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
+ ):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is True
- assert batch_sizes == [100, 100, 5]
- assert len(await catalog.get_nodes()) == 205
- finally:
- await catalog.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert batch_sizes == [100, 100, 5]
+ assert len(await catalog.get_nodes()) == 205
+ finally:
+ await catalog.close()
-def test_update_catalog_deletes_in_batches_of_at_most_100_paths():
+@pytest.mark.asyncio
+async def test_update_catalog_deletes_in_batches_of_at_most_100_paths():
"""Large delete sets use the same bounded failure domain as upserts."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- deleted = [cwd / "daily" / f"{index:03d}.md" for index in range(205)]
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- batch_sizes = []
- original_delete = catalog.delete
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ deleted = [cwd / "daily" / f"{index:03d}.md" for index in range(205)]
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ batch_sizes = []
+ original_delete = catalog.delete
- async def record_delete(paths):
- batch_sizes.append(len(paths))
- await original_delete(paths)
+ async def record_delete(paths):
+ batch_sizes.append(len(paths))
+ await original_delete(paths)
- try:
- step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
- changes = [{"change": "deleted", "path": str(path)} for path in deleted]
- with patch.object(catalog, "delete", side_effect=record_delete):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
+ changes = [{"change": "deleted", "path": str(path)} for path in deleted]
+ with patch.object(catalog, "delete", side_effect=record_delete):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is True
- assert batch_sizes == [100, 100, 5]
- assert len(response.answer) == 205
- finally:
- await catalog.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert batch_sizes == [100, 100, 5]
+ assert len(response.answer) == 205
+ finally:
+ await catalog.close()
-def test_update_index_memory_budget_can_reduce_batches_to_one_file():
+@pytest.mark.asyncio
+async def test_update_index_memory_budget_can_reduce_batches_to_one_file():
"""A file larger than the target batch budget is still processed alone."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- paths = [write_file(cwd / "daily" / f"{index}.md", f"# Note {index}\nbody\n") for index in range(3)]
- fs = LocalFileStore(name="default", embedding_store="")
- chunker = DefaultFileChunker(supported_extensions=["md"])
- await fs.start()
- await chunker.start()
- batch_sizes = []
- original_upsert = fs.upsert
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ paths = [write_file(cwd / "daily" / f"{index}.md", f"# Note {index}\nbody\n") for index in range(3)]
+ fs = LocalFileStore(name="default", embedding_store="")
+ chunker = DefaultFileChunker(supported_extensions=["md"])
+ await fs.start()
+ await chunker.start()
+ batch_sizes = []
+ original_upsert = fs.upsert
- async def record_upsert(items):
- batch_sizes.append(len(items))
- await original_upsert(items)
+ async def record_upsert(items):
+ batch_sizes.append(len(items))
+ await original_upsert(items)
- try:
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"default": chunker}}
- step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
- changes = [{"change": "added", "path": str(path)} for path in paths]
- available = MagicMock(available=1_000)
- with (
- patch.object(fs, "upsert", side_effect=record_upsert),
- patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
- ):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"default": chunker}}
+ step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
+ changes = [{"change": "added", "path": str(path)} for path in paths]
+ available = MagicMock(available=1_000)
+ with (
+ patch.object(fs, "upsert", side_effect=record_upsert),
+ patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
+ ):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is True
- assert batch_sizes == [1, 1, 1]
- assert len(await fs.get_nodes()) == 3
- finally:
- await chunker.close()
- await fs.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert batch_sizes == [1, 1, 1]
+ assert len(await fs.get_nodes()) == 3
+ finally:
+ await chunker.close()
+ await fs.close()
-def test_update_catalog_memory_target_limits_cumulative_batch_size():
+@pytest.mark.asyncio
+async def test_update_catalog_memory_target_limits_cumulative_batch_size():
"""The memory target applies to the accumulated batch, not only individual files."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(3)]
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- batch_sizes = []
- original_upsert = catalog.upsert
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(3)]
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ batch_sizes = []
+ original_upsert = catalog.upsert
- async def record_upsert(nodes):
- batch_sizes.append(len(nodes))
- await original_upsert(nodes)
+ async def record_upsert(nodes):
+ batch_sizes.append(len(nodes))
+ await original_upsert(nodes)
- try:
- step = UpdateCatalogStep(
- file_catalog=catalog,
- persist=False,
- batch_memory_target_bytes=64 * 1024,
- app_context=_make_app_context(cwd),
- )
- changes = [{"change": "added", "path": str(path)} for path in paths]
- available = MagicMock(available=8 * 1024 * 1024 * 1024)
- with (
- patch.object(catalog, "upsert", side_effect=record_upsert),
- patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
- ):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ step = UpdateCatalogStep(
+ file_catalog=catalog,
+ persist=False,
+ batch_memory_target_bytes=64 * 1024,
+ app_context=_make_app_context(cwd),
+ )
+ changes = [{"change": "added", "path": str(path)} for path in paths]
+ available = MagicMock(available=8 * 1024 * 1024 * 1024)
+ with (
+ patch.object(catalog, "upsert", side_effect=record_upsert),
+ patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
+ ):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is True
- assert batch_sizes == [2, 1]
- finally:
- await catalog.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert batch_sizes == [2, 1]
+ finally:
+ await catalog.close()
@pytest.mark.parametrize(
@@ -681,38 +627,36 @@ def test_update_step_accepts_configured_batch_memory_settings():
@pytest.mark.parametrize("estimate_method", ["estimate_source_memory", "estimate_item_memory"])
-def test_update_catalog_memory_estimate_failure_processes_each_file_alone(estimate_method):
+@pytest.mark.asyncio
+async def test_update_catalog_memory_estimate_failure_processes_each_file_alone(estimate_method):
"""Advisory estimate failures isolate files without skipping their updates."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(2)]
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- batch_sizes = []
- original_upsert = catalog.upsert
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(2)]
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ batch_sizes = []
+ original_upsert = catalog.upsert
- async def record_upsert(nodes):
- batch_sizes.append(len(nodes))
- await original_upsert(nodes)
+ async def record_upsert(nodes):
+ batch_sizes.append(len(nodes))
+ await original_upsert(nodes)
- try:
- step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
- changes = [{"change": "added", "path": str(path)} for path in paths]
- with (
- patch.object(step, estimate_method, side_effect=RuntimeError("estimate failed")),
- patch.object(catalog, "upsert", side_effect=record_upsert),
- ):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ step = UpdateCatalogStep(file_catalog=catalog, persist=False, app_context=_make_app_context(cwd))
+ changes = [{"change": "added", "path": str(path)} for path in paths]
+ with (
+ patch.object(step, estimate_method, side_effect=RuntimeError("estimate failed")),
+ patch.object(catalog, "upsert", side_effect=record_upsert),
+ ):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is True
- assert batch_sizes == [1, 1]
- assert len(await catalog.get_nodes()) == 2
- finally:
- await catalog.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert batch_sizes == [1, 1]
+ assert len(await catalog.get_nodes()) == 2
+ finally:
+ await catalog.close()
def test_update_catalog_releases_flushed_item_before_building_next_file():
@@ -761,47 +705,45 @@ def test_update_catalog_releases_flushed_item_before_building_next_file():
asyncio.run(run())
-def test_update_catalog_continues_after_one_batch_fails():
+@pytest.mark.asyncio
+async def test_update_catalog_continues_after_one_batch_fails():
"""An upsert failure is isolated to its bounded batch and later files continue."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(3)]
- catalog = LocalFileCatalog(name="test_catalog")
- await catalog.start()
- original_upsert = catalog.upsert
- call_count = 0
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ paths = [write_file(cwd / "daily" / f"{index}.md", "x") for index in range(3)]
+ catalog = LocalFileCatalog(name="test_catalog")
+ await catalog.start()
+ original_upsert = catalog.upsert
+ call_count = 0
- async def fail_first_batch(nodes):
- nonlocal call_count
- call_count += 1
- if call_count == 1:
- raise RuntimeError("batch failed")
- await original_upsert(nodes)
+ async def fail_first_batch(nodes):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise RuntimeError("batch failed")
+ await original_upsert(nodes)
- try:
- step = UpdateCatalogStep(
- file_catalog=catalog,
- persist=False,
- batch_max_files=2,
- app_context=_make_app_context(cwd),
- )
- changes = [{"change": "added", "path": str(path)} for path in paths]
- available = MagicMock(available=8 * 1024 * 1024 * 1024)
- with (
- patch.object(catalog, "upsert", side_effect=fail_first_batch),
- patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
- ):
- response = await step(RuntimeContext(changes=changes))
+ try:
+ step = UpdateCatalogStep(
+ file_catalog=catalog,
+ persist=False,
+ batch_max_files=2,
+ app_context=_make_app_context(cwd),
+ )
+ changes = [{"change": "added", "path": str(path)} for path in paths]
+ available = MagicMock(available=8 * 1024 * 1024 * 1024)
+ with (
+ patch.object(catalog, "upsert", side_effect=fail_first_batch),
+ patch("reme.steps.index.update_changes.psutil.virtual_memory", return_value=available),
+ ):
+ response = await step(RuntimeContext(changes=changes))
- assert response.success is False
- assert [result["success"] for result in response.answer] == [False, False, True]
- assert [node.path for node in await catalog.get_nodes()] == ["daily/2.md"]
- finally:
- await catalog.close()
-
- asyncio.run(run())
+ assert response.success is False
+ assert [result["success"] for result in response.answer] == [False, False, True]
+ assert [node.path for node in await catalog.get_nodes()] == ["daily/2.md"]
+ finally:
+ await catalog.close()
def test_update_catalog_yields_to_event_loop_while_building_batch():
@@ -855,164 +797,156 @@ class _CountingEmbeddingStore:
return nodes
-def test_update_index_modified_file_reuses_unchanged_embedding():
+@pytest.mark.asyncio
+async def test_update_index_modified_file_reuses_unchanged_embedding():
"""The step relies on replace-aware upsert instead of deleting reusable chunks first."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- source = write_file(cwd / "daily" / "a.md", "alpha")
- fs = LocalFileStore(name="default", embedding_store="")
- chunker = DefaultFileChunker(supported_extensions=["md"])
- await fs.start()
- await chunker.start()
- embedding_store = _CountingEmbeddingStore()
- fs.embedding_store = embedding_store
- try:
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"default": chunker}}
- await fs.upsert([await chunker.chunk(source)])
- assert embedding_store.calls == 1
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ source = write_file(cwd / "daily" / "a.md", "alpha")
+ fs = LocalFileStore(name="default", embedding_store="")
+ chunker = DefaultFileChunker(supported_extensions=["md"])
+ await fs.start()
+ await chunker.start()
+ embedding_store = _CountingEmbeddingStore()
+ fs.embedding_store = embedding_store
+ try:
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"default": chunker}}
+ await fs.upsert([await chunker.chunk(source)])
+ assert embedding_store.calls == 1
- step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
- with patch.object(fs, "delete", wraps=fs.delete) as delete_mock:
- response = await step(
- RuntimeContext(changes=[{"change": "modified", "path": str(source)}]),
- )
+ step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
+ with patch.object(fs, "delete", wraps=fs.delete) as delete_mock:
+ response = await step(
+ RuntimeContext(changes=[{"change": "modified", "path": str(source)}]),
+ )
- assert response.success is True
- assert embedding_store.calls == 1
- delete_mock.assert_not_awaited()
- finally:
- await chunker.close()
- await fs.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert embedding_store.calls == 1
+ delete_mock.assert_not_awaited()
+ finally:
+ await chunker.close()
+ await fs.close()
-def test_update_index_replaces_large_malformed_json_without_changing_source():
+@pytest.mark.asyncio
+async def test_update_index_replaces_large_malformed_json_without_changing_source():
"""Malformed JSON uses the fallback and replaces its old chunks."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- source = cwd / "daily" / "broken.json"
- sibling = cwd / "daily" / "healthy.json"
- original = ("{" + "x" * 2000).encode()
- replacement = ("{" + "y" * 2000).encode()
- source.parent.mkdir(parents=True)
- source.write_bytes(original)
- sibling.write_text('{"healthy": true}', encoding="utf-8")
- fs = LocalFileStore(name="default", embedding_store="")
- chunker = JsonFileChunker(supported_extensions=["json"], chunk_chars=256)
- await fs.start()
- await chunker.start()
- try:
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"json": chunker}}
- step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
- response = await step(
- RuntimeContext(
- changes=[
- {"change": "added", "path": str(source)},
- {"change": "added", "path": str(sibling)},
- ],
- ),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ source = cwd / "daily" / "broken.json"
+ sibling = cwd / "daily" / "healthy.json"
+ original = ("{" + "x" * 2000).encode()
+ replacement = ("{" + "y" * 2000).encode()
+ source.parent.mkdir(parents=True)
+ source.write_bytes(original)
+ sibling.write_text('{"healthy": true}', encoding="utf-8")
+ fs = LocalFileStore(name="default", embedding_store="")
+ chunker = JsonFileChunker(supported_extensions=["json"], chunk_chars=256)
+ await fs.start()
+ await chunker.start()
+ try:
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {ComponentEnum.FILE_CHUNKER: {"json": chunker}}
+ step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
+ response = await step(
+ RuntimeContext(
+ changes=[
+ {"change": "added", "path": str(source)},
+ {"change": "added", "path": str(sibling)},
+ ],
+ ),
+ )
- assert response.success is True
- assert source.read_bytes() == original
- nodes = {node.path: node for node in await fs.get_nodes()}
- old_chunk_ids = nodes["daily/broken.json"].chunk_ids
- assert nodes["daily/healthy.json"].chunk_ids
+ assert response.success is True
+ assert source.read_bytes() == original
+ nodes = {node.path: node for node in await fs.get_nodes()}
+ old_chunk_ids = nodes["daily/broken.json"].chunk_ids
+ assert nodes["daily/healthy.json"].chunk_ids
- source.write_bytes(replacement)
- step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
- response = await step(RuntimeContext(changes=[{"change": "modified", "path": str(source)}]))
+ source.write_bytes(replacement)
+ step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
+ response = await step(RuntimeContext(changes=[{"change": "modified", "path": str(source)}]))
- assert response.success is True
- assert source.read_bytes() == replacement
- nodes = {node.path: node for node in await fs.get_nodes()}
- assert nodes["daily/broken.json"].chunk_ids != old_chunk_ids
- assert all(chunk_id not in fs.file_chunks for chunk_id in old_chunk_ids)
- assert nodes["daily/healthy.json"].chunk_ids
- finally:
- await chunker.close()
- await fs.close()
-
- asyncio.run(run())
+ assert response.success is True
+ assert source.read_bytes() == replacement
+ nodes = {node.path: node for node in await fs.get_nodes()}
+ assert nodes["daily/broken.json"].chunk_ids != old_chunk_ids
+ assert all(chunk_id not in fs.file_chunks for chunk_id in old_chunk_ids)
+ assert nodes["daily/healthy.json"].chunk_ids
+ finally:
+ await chunker.close()
+ await fs.close()
-def test_update_index_skips_oversized_file_and_clears_stale_index():
+@pytest.mark.asyncio
+async def test_update_index_skips_oversized_file_and_clears_stale_index():
"""Oversized content is not read and any previous index entry is removed."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- source = write_file(cwd / "daily" / "a.md", "small")
- fs = LocalFileStore(name="default", embedding_store="")
- chunker = DefaultFileChunker()
- await fs.start()
- await chunker.start()
- try:
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {
- ComponentEnum.FILE_CHUNKER: {"default": chunker},
- }
- step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ source = write_file(cwd / "daily" / "a.md", "small")
+ fs = LocalFileStore(name="default", embedding_store="")
+ chunker = DefaultFileChunker()
+ await fs.start()
+ await chunker.start()
+ try:
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {
+ ComponentEnum.FILE_CHUNKER: {"default": chunker},
+ }
+ step = UpdateIndexStep(file_store=fs, persist=False, app_context=app_ctx)
- added = await step(
- RuntimeContext(
- changes=[{"change": "added", "path": str(source)}],
- max_file_bytes=10,
- ),
- )
- assert added.success is True
- assert {node.path for node in await fs.get_nodes()} == {"daily/a.md"}
+ added = await step(
+ RuntimeContext(
+ changes=[{"change": "added", "path": str(source)}],
+ max_file_bytes=10,
+ ),
+ )
+ assert added.success is True
+ assert {node.path for node in await fs.get_nodes()} == {"daily/a.md"}
- source.write_text("now too large", encoding="utf-8")
- modified = await step(
- RuntimeContext(
- changes=[{"change": "modified", "path": str(source)}],
- max_file_bytes=10,
- ),
- )
+ source.write_text("now too large", encoding="utf-8")
+ modified = await step(
+ RuntimeContext(
+ changes=[{"change": "modified", "path": str(source)}],
+ max_file_bytes=10,
+ ),
+ )
- assert modified.success is True
- assert modified.answer[0]["skipped"] is True
- assert modified.answer[0]["reason"] == "file_too_large"
- assert await fs.get_nodes() == []
- finally:
- await chunker.close()
- await fs.close()
-
- asyncio.run(run())
+ assert modified.success is True
+ assert modified.answer[0]["skipped"] is True
+ assert modified.answer[0]["reason"] == "file_too_large"
+ assert await fs.get_nodes() == []
+ finally:
+ await chunker.close()
+ await fs.close()
-def test_update_index_handles_file_removed_before_stat():
+@pytest.mark.asyncio
+async def test_update_index_handles_file_removed_before_stat():
"""A file disappearing after is_file is reported without aborting the batch."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- step = UpdateIndexStep(persist=False, app_context=_make_app_context(Path.cwd()))
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ step = UpdateIndexStep(persist=False, app_context=_make_app_context(Path.cwd()))
- with (
- patch.object(Path, "is_file", return_value=True),
- patch.object(Path, "stat", side_effect=FileNotFoundError("file disappeared")),
- ):
- response = await step(RuntimeContext(changes=[{"change": "modified", "path": "daily/a.md"}]))
+ with (
+ patch.object(Path, "is_file", return_value=True),
+ patch.object(Path, "stat", side_effect=FileNotFoundError("file disappeared")),
+ ):
+ response = await step(RuntimeContext(changes=[{"change": "modified", "path": "daily/a.md"}]))
- assert response.success is False
- assert response.answer == [
- {
- "change": "modified",
- "path": "daily/a.md",
- "success": False,
- "error": "file disappeared",
- },
- ]
-
- asyncio.run(run())
+ assert response.success is False
+ assert response.answer == [
+ {
+ "change": "modified",
+ "path": "daily/a.md",
+ "success": False,
+ "error": "file disappeared",
+ },
+ ]
def test_update_index_initial_scan_removes_file_disappeared_during_parse():
@@ -1092,32 +1026,28 @@ def test_update_index_initial_scan_fails_when_source_never_stabilizes():
asyncio.run(run())
-def test_update_index_reports_memory_estimation_failure_without_aborting():
+@pytest.mark.asyncio
+async def test_update_index_reports_memory_estimation_failure_without_aborting():
"""A missing chunker is isolated to the affected file and returned as a normal failure."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- source = write_file(cwd / "daily" / "a.unknown", "alpha")
- app_ctx = _make_app_context(cwd)
- app_ctx.components = {ComponentEnum.FILE_CHUNKER: {}}
- step = UpdateIndexStep(persist=False, app_context=app_ctx)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ source = write_file(cwd / "daily" / "a.unknown", "alpha")
+ app_ctx = _make_app_context(cwd)
+ app_ctx.components = {ComponentEnum.FILE_CHUNKER: {}}
+ step = UpdateIndexStep(persist=False, app_context=app_ctx)
- response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
+ response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- assert response.success is False
- assert response.answer == [
- {
- "change": "added",
- "path": str(source),
- "success": False,
- "error": (
- f"No file chunker supports {source} (suffix='unknown') and no default chunker is configured"
- ),
- },
- ]
-
- asyncio.run(run())
+ assert response.success is False
+ assert response.answer == [
+ {
+ "change": "added",
+ "path": str(source),
+ "success": False,
+ "error": (f"No file chunker supports {source} (suffix='unknown') and no default chunker is configured"),
+ },
+ ]
def test_index_update_loop_init_dispatch_updates_store_across_batches():
@@ -1176,7 +1106,6 @@ def test_index_update_loop_init_dispatch_updates_store_across_batches():
finally:
await chunker.close()
await fs.close()
- print("✓ test_index_update_loop_init_dispatch_updates_store_across_batches passed")
asyncio.run(run())
@@ -1369,7 +1298,6 @@ def test_digest_watch_loop_init_dispatch_updates_named_catalog_and_logs():
finally:
await resource_catalog.close()
await digest_catalog.close()
- print("✓ test_digest_watch_loop_init_dispatch_updates_named_catalog_and_logs passed")
asyncio.run(run())
@@ -1392,8 +1320,6 @@ def test_watch_changes_default_low_power_timing():
assert custom.step == 250
assert custom.poll_delay_ms == 3000
- print("✓ test_watch_changes_default_low_power_timing passed")
-
def test_watch_changes_requires_stop_event():
"""Missing stop_event in context raises a clear error."""
@@ -1411,30 +1337,26 @@ def test_watch_changes_requires_stop_event():
assert "stop_event" in str(e)
else:
raise AssertionError("expected RuntimeError")
- print("✓ test_watch_changes_requires_stop_event passed")
asyncio.run(run())
-def test_watch_changes_raises_no_valid_paths():
+@pytest.mark.asyncio
+async def test_watch_changes_raises_no_valid_paths():
"""With no valid watch_paths, the step raises."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- step = WatchChangesStep(app_context=app_ctx)
- stop = asyncio.Event()
- step.context = RuntimeContext(stop_event=stop, watch_dirs=["daily_dir"], watch_suffixes=["md"])
- try:
- await step.execute()
- except RuntimeError as e:
- assert "No valid watch paths" in str(e)
- else:
- raise AssertionError("expected RuntimeError")
- print("✓ test_watch_changes_raises_no_valid_paths passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ step = WatchChangesStep(app_context=app_ctx)
+ stop = asyncio.Event()
+ step.context = RuntimeContext(stop_event=stop, watch_dirs=["daily_dir"], watch_suffixes=["md"])
+ try:
+ await step.execute()
+ except RuntimeError as e:
+ assert "No valid watch paths" in str(e)
+ else:
+ raise AssertionError("expected RuntimeError")
def test_watch_changes_filter_matches_rules():
@@ -1455,8 +1377,6 @@ def test_watch_changes_filter_matches_rules():
assert not step._filter(Change.added, str(workspace / "daily/foo.txt"))
assert not step._filter(Change.added, str(workspace / "resource/file.md"))
- print("✓ test_watch_changes_filter_matches_rules passed")
-
def test_watch_changes_filter_matches_uppercase_suffixes():
"""The live watcher accepts uppercase extensions configured in lowercase."""
@@ -1478,1040 +1398,925 @@ def test_watch_changes_dispatch_steps_list():
step = WatchChangesStep(dispatch_steps=["update_catalog_step", "auto_resource_step"])
assert step.dispatch_step_specs == ["update_catalog_step", "auto_resource_step"]
- print("✓ test_watch_changes_dispatch_steps_list passed")
-
-def test_auto_resource_batch_deleted_changes():
+@pytest.mark.asyncio
+async def test_auto_resource_batch_deleted_changes():
"""AutoResourceStep accepts a batch of change dicts from dispatch_steps."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- filename = "file.md"
- note_stem = _compute_note_stem(filename)
- note_path = cwd / "daily" / "2026-01-01" / f"{note_stem}.md"
- write_file(
- note_path,
- "---\nname: test\nsource_resource: '[[resource/2026-01-01/file.md]]'\n---\nbody\n",
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ filename = "file.md"
+ note_stem = _compute_note_stem(filename)
+ note_path = cwd / "daily" / "2026-01-01" / f"{note_stem}.md"
+ write_file(
+ note_path,
+ "---\nname: test\nsource_resource: '[[resource/2026-01-01/file.md]]'\n---\nbody\n",
+ )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
- ctx = RuntimeContext(
- changes=[
- {"change": "deleted", "path": str(cwd / "resource" / "2026-01-01" / filename)},
- ],
- )
- resp = await step(ctx)
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
+ ctx = RuntimeContext(
+ changes=[
+ {"change": "deleted", "path": str(cwd / "resource" / "2026-01-01" / filename)},
+ ],
+ )
+ resp = await step(ctx)
- assert resp.success is True
- assert resp.answer == "Deleted resource note: daily/2026-01-01/file.md"
- assert resp.metadata["processed"] == 1
- assert resp.metadata["results"][0]["path"] == "resource/2026-01-01/file.md"
- assert not note_path.exists()
- finally:
- await fs.close()
- print("✓ test_auto_resource_batch_deleted_changes passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.answer == "Deleted resource note: daily/2026-01-01/file.md"
+ assert resp.metadata["processed"] == 1
+ assert resp.metadata["results"][0]["path"] == "resource/2026-01-01/file.md"
+ assert not note_path.exists()
+ finally:
+ await fs.close()
-def test_auto_resource_skips_oversized_file_before_reading():
+@pytest.mark.asyncio
+async def test_auto_resource_skips_oversized_file_before_reading():
"""Oversized resources are reported as successful skips without an agent call."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- source = write_file(cwd / "resource" / "2026-01-01" / "large.txt", "too large")
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(
- changes=[{"change": "added", "path": str(source)}],
- max_file_bytes=4,
- ),
- )
-
- result = resp.metadata["results"][0]
- assert resp.success is True
- assert result["metadata"]["oversized"] is True
- assert result["metadata"]["reason"] == "file_too_large"
- assert resp.metadata["modified"] is False
- assert wrapper.inputs == ""
- finally:
- await fs.close()
-
- asyncio.run(run())
-
-
-def test_auto_resource_batch_keeps_result_metadata_isolated():
- """Oversized metadata from one resource does not leak into the next result."""
-
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- large = write_file(cwd / "resource" / "2026-01-01" / "large.txt", "too large")
- small = write_file(cwd / "resource" / "2026-01-01" / "small.txt", "ok")
- second_large = write_file(cwd / "resource" / "2026-01-01" / "second-large.txt", "also large")
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(
- changes=[
- {"change": "added", "path": str(large)},
- {"change": "added", "path": str(small)},
- {"change": "added", "path": str(second_large)},
- ],
- max_file_bytes=4,
- ),
- )
-
- large_metadata = resp.metadata["results"][0]["metadata"]
- small_metadata = resp.metadata["results"][1]["metadata"]
- second_large_metadata = resp.metadata["results"][2]["metadata"]
- assert large_metadata["oversized"] is True
- assert large_metadata["reason"] == "file_too_large"
- assert "oversized" not in small_metadata
- assert "reason" not in small_metadata
- assert "size_bytes" not in small_metadata
- assert second_large_metadata["oversized"] is True
- assert "created" not in second_large_metadata
- assert "agent_session_id" not in second_large_metadata
- finally:
- await fs.close()
-
- asyncio.run(run())
-
-
-def test_auto_resource_handles_file_removed_before_stat():
- """A resource disappearing after is_file becomes one failed batch result."""
-
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- source = write_file(cwd / "resource" / "2026-01-01" / "vanishing.txt", "content")
- original_is_file = Path.is_file
- original_stat = Path.stat
-
- def existing_is_file(path, *args, **kwargs):
- if Path(path).name == source.name:
- return True
- return original_is_file(path, *args, **kwargs)
-
- def disappearing_stat(path, *args, **kwargs):
- if Path(path).name == source.name:
- raise FileNotFoundError("file disappeared")
- return original_stat(path, *args, **kwargs)
-
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
- with (
- patch.object(Path, "is_file", existing_is_file),
- patch.object(Path, "stat", disappearing_stat),
- ):
- resp = await step(
- RuntimeContext(changes=[{"change": "added", "path": str(source)}]),
- )
-
- result = resp.metadata["results"][0]
- assert resp.success is False
- assert result["success"] is False
- assert result["metadata"]["action"] == "failed"
- assert result["metadata"]["error"] == "file disappeared"
- finally:
- await fs.close()
-
- asyncio.run(run())
-
-
-def test_auto_resource_rejects_paths_outside_resource_scope_before_agent_call():
- """Traversal, outside absolute paths, and escaping symlinks fail closed."""
-
- async def run():
- with (
- tempfile.TemporaryDirectory() as tmpdir,
- tempfile.TemporaryDirectory() as outside_dir,
- temp_chdir(tmpdir),
- ):
- workspace = Path.cwd()
- outside = write_file(Path(outside_dir) / "outside.txt", "secret")
- workspace_outside = write_file(workspace / "daily" / "outside.txt", "workspace secret")
- link = workspace / "resource" / "2026-01-01" / "escape.txt"
- link.parent.mkdir(parents=True, exist_ok=True)
- link.symlink_to(outside)
- wrapper = _FakeAgentWrapper()
- step = AutoTextResourceStep(app_context=_make_app_context(workspace), agent_wrapper=wrapper)
-
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ source = write_file(cwd / "resource" / "2026-01-01" / "large.txt", "too large")
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
resp = await step(
RuntimeContext(
- changes=[
- {"change": "added", "path": "resource/2026-01-01/../../../outside.txt"},
- {"change": "modified", "path": str(outside)},
- {"change": "added", "path": str(workspace_outside)},
- {"change": "added", "path": str(link)},
- ],
- ),
- )
-
- assert resp.success is False
- assert wrapper.inputs == ""
- assert len(resp.metadata["results"]) == 4
- assert all(result["metadata"]["action"] == "failed" for result in resp.metadata["results"])
- assert all(result["metadata"]["modified"] is False for result in resp.metadata["results"])
- assert outside.read_text(encoding="utf-8") == "secret"
- assert workspace_outside.read_text(encoding="utf-8") == "workspace secret"
-
- asyncio.run(run())
-
-
-def test_auto_resource_rejects_traversal_delete_without_touching_note():
- """A malicious deleted path cannot reach or remove a daily note."""
-
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- note = write_file(
- workspace / "daily" / "2026-01-01" / "outside.md",
- "---\nname: outside\n"
- "source_resource: '[[resource/2026-01-01/../../../outside.txt]]'\n---\nkeep me\n",
- )
- step = AutoTextResourceStep(app_context=_make_app_context(workspace))
-
- resp = await step(
- RuntimeContext(
- changes=[
- {"change": "deleted", "path": "resource/2026-01-01/../../../outside.txt"},
- ],
+ changes=[{"change": "added", "path": str(source)}],
+ max_file_bytes=4,
),
)
+ result = resp.metadata["results"][0]
+ assert resp.success is True
+ assert result["metadata"]["oversized"] is True
+ assert result["metadata"]["reason"] == "file_too_large"
+ assert resp.metadata["modified"] is False
+ assert wrapper.inputs == ""
+ finally:
+ await fs.close()
+
+
+@pytest.mark.asyncio
+async def test_auto_resource_batch_keeps_result_metadata_isolated():
+ """Oversized metadata from one resource does not leak into the next result."""
+
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ large = write_file(cwd / "resource" / "2026-01-01" / "large.txt", "too large")
+ small = write_file(cwd / "resource" / "2026-01-01" / "small.txt", "ok")
+ second_large = write_file(cwd / "resource" / "2026-01-01" / "second-large.txt", "also large")
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(
+ changes=[
+ {"change": "added", "path": str(large)},
+ {"change": "added", "path": str(small)},
+ {"change": "added", "path": str(second_large)},
+ ],
+ max_file_bytes=4,
+ ),
+ )
+
+ large_metadata = resp.metadata["results"][0]["metadata"]
+ small_metadata = resp.metadata["results"][1]["metadata"]
+ second_large_metadata = resp.metadata["results"][2]["metadata"]
+ assert large_metadata["oversized"] is True
+ assert large_metadata["reason"] == "file_too_large"
+ assert "oversized" not in small_metadata
+ assert "reason" not in small_metadata
+ assert "size_bytes" not in small_metadata
+ assert second_large_metadata["oversized"] is True
+ assert "created" not in second_large_metadata
+ assert "agent_session_id" not in second_large_metadata
+ finally:
+ await fs.close()
+
+
+@pytest.mark.asyncio
+async def test_auto_resource_handles_file_removed_before_stat():
+ """A resource disappearing after is_file becomes one failed batch result."""
+
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ source = write_file(cwd / "resource" / "2026-01-01" / "vanishing.txt", "content")
+ original_is_file = Path.is_file
+ original_stat = Path.stat
+
+ def existing_is_file(path, *args, **kwargs):
+ if Path(path).name == source.name:
+ return True
+ return original_is_file(path, *args, **kwargs)
+
+ def disappearing_stat(path, *args, **kwargs):
+ if Path(path).name == source.name:
+ raise FileNotFoundError("file disappeared")
+ return original_stat(path, *args, **kwargs)
+
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
+ with (
+ patch.object(Path, "is_file", existing_is_file),
+ patch.object(Path, "stat", disappearing_stat),
+ ):
+ resp = await step(
+ RuntimeContext(changes=[{"change": "added", "path": str(source)}]),
+ )
+
result = resp.metadata["results"][0]
assert resp.success is False
+ assert result["success"] is False
assert result["metadata"]["action"] == "failed"
- assert result["metadata"]["modified"] is False
- assert note.read_text(encoding="utf-8").endswith("keep me\n")
-
- asyncio.run(run())
+ assert result["metadata"]["error"] == "file disappeared"
+ finally:
+ await fs.close()
-def test_auto_resource_internal_symlink_keeps_logical_source_identity():
+@pytest.mark.asyncio
+async def test_auto_resource_rejects_paths_outside_resource_scope_before_agent_call():
+ """Traversal, outside absolute paths, and escaping symlinks fail closed."""
+
+ with (
+ tempfile.TemporaryDirectory() as tmpdir,
+ tempfile.TemporaryDirectory() as outside_dir,
+ temp_chdir(tmpdir),
+ ):
+ workspace = Path.cwd()
+ outside = write_file(Path(outside_dir) / "outside.txt", "secret")
+ workspace_outside = write_file(workspace / "daily" / "outside.txt", "workspace secret")
+ link = workspace / "resource" / "2026-01-01" / "escape.txt"
+ link.parent.mkdir(parents=True, exist_ok=True)
+ link.symlink_to(outside)
+ wrapper = _FakeAgentWrapper()
+ step = AutoTextResourceStep(app_context=_make_app_context(workspace), agent_wrapper=wrapper)
+
+ resp = await step(
+ RuntimeContext(
+ changes=[
+ {"change": "added", "path": "resource/2026-01-01/../../../outside.txt"},
+ {"change": "modified", "path": str(outside)},
+ {"change": "added", "path": str(workspace_outside)},
+ {"change": "added", "path": str(link)},
+ ],
+ ),
+ )
+
+ assert resp.success is False
+ assert wrapper.inputs == ""
+ assert len(resp.metadata["results"]) == 4
+ assert all(result["metadata"]["action"] == "failed" for result in resp.metadata["results"])
+ assert all(result["metadata"]["modified"] is False for result in resp.metadata["results"])
+ assert outside.read_text(encoding="utf-8") == "secret"
+ assert workspace_outside.read_text(encoding="utf-8") == "workspace secret"
+
+
+@pytest.mark.asyncio
+async def test_auto_resource_rejects_traversal_delete_without_touching_note():
+ """A malicious deleted path cannot reach or remove a daily note."""
+
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ note = write_file(
+ workspace / "daily" / "2026-01-01" / "outside.md",
+ "---\nname: outside\nsource_resource: '[[resource/2026-01-01/../../../outside.txt]]'\n---\nkeep me\n",
+ )
+ step = AutoTextResourceStep(app_context=_make_app_context(workspace))
+
+ resp = await step(
+ RuntimeContext(
+ changes=[
+ {"change": "deleted", "path": "resource/2026-01-01/../../../outside.txt"},
+ ],
+ ),
+ )
+
+ result = resp.metadata["results"][0]
+ assert resp.success is False
+ assert result["metadata"]["action"] == "failed"
+ assert result["metadata"]["modified"] is False
+ assert note.read_text(encoding="utf-8").endswith("keep me\n")
+
+
+@pytest.mark.asyncio
+async def test_auto_resource_internal_symlink_keeps_logical_source_identity():
"""A safe internal symlink is read by target while provenance keeps the link path."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- target = write_file(workspace / "resource" / "2026-01-01" / "target.txt", "visible")
- link = workspace / "resource" / "2026-01-01" / "link.txt"
- link.symlink_to(target)
- captured = {}
- step = AutoTextResourceStep(app_context=_make_app_context(workspace))
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ target = write_file(workspace / "resource" / "2026-01-01" / "target.txt", "visible")
+ link = workspace / "resource" / "2026-01-01" / "link.txt"
+ link.symlink_to(target)
+ captured = {}
+ step = AutoTextResourceStep(app_context=_make_app_context(workspace))
- async def fake_upsert(file_path, date_str, note_stem, added, source_path):
- captured.update(
- {
- "file_path": file_path,
- "date_str": date_str,
- "note_stem": note_stem,
- "added": added,
- "source_path": source_path,
- },
- )
- step.context.response.success = True
- step.context.response.answer = "ok"
+ async def fake_upsert(file_path, date_str, note_stem, added, source_path):
+ captured.update(
+ {
+ "file_path": file_path,
+ "date_str": date_str,
+ "note_stem": note_stem,
+ "added": added,
+ "source_path": source_path,
+ },
+ )
+ step.context.response.success = True
+ step.context.response.answer = "ok"
- step._handle_upsert = fake_upsert
- resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(link)}]))
+ step._handle_upsert = fake_upsert
+ resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(link)}]))
- assert resp.success is True
- assert captured == {
- "file_path": "resource/2026-01-01/link.txt",
- "date_str": "2026-01-01",
- "note_stem": "link",
- "added": True,
- "source_path": target.resolve(),
- }
-
- asyncio.run(run())
+ assert resp.success is True
+ assert captured == {
+ "file_path": "resource/2026-01-01/link.txt",
+ "date_str": "2026-01-01",
+ "note_stem": "link",
+ "added": True,
+ "source_path": target.resolve(),
+ }
-def test_auto_resource_accepts_absolute_resource_dir_inside_workspace():
+@pytest.mark.asyncio
+async def test_auto_resource_accepts_absolute_resource_dir_inside_workspace():
"""An absolute in-workspace resource_dir keeps a workspace-relative source identity."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- resource_dir = workspace / "assets"
- source = write_file(resource_dir / "2026-01-01" / "report.txt", "visible")
- step = AutoTextResourceStep(app_context=_make_app_context(workspace, resource_dir=str(resource_dir)))
- captured = {}
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ resource_dir = workspace / "assets"
+ source = write_file(resource_dir / "2026-01-01" / "report.txt", "visible")
+ step = AutoTextResourceStep(app_context=_make_app_context(workspace, resource_dir=str(resource_dir)))
+ captured = {}
- async def fake_upsert(file_path, date_str, note_stem, added, source_path):
- captured.update(
- {
- "file_path": file_path,
- "date_str": date_str,
- "note_stem": note_stem,
- "added": added,
- "source_path": source_path,
- },
- )
- step.context.response.success = True
- step.context.response.answer = "ok"
+ async def fake_upsert(file_path, date_str, note_stem, added, source_path):
+ captured.update(
+ {
+ "file_path": file_path,
+ "date_str": date_str,
+ "note_stem": note_stem,
+ "added": added,
+ "source_path": source_path,
+ },
+ )
+ step.context.response.success = True
+ step.context.response.answer = "ok"
- step._handle_upsert = fake_upsert
- response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
+ step._handle_upsert = fake_upsert
+ response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- assert response.success is True
- assert captured == {
- "file_path": "assets/2026-01-01/report.txt",
- "date_str": "2026-01-01",
- "note_stem": "report",
- "added": True,
- "source_path": source.resolve(),
- }
-
- asyncio.run(run())
+ assert response.success is True
+ assert captured == {
+ "file_path": "assets/2026-01-01/report.txt",
+ "date_str": "2026-01-01",
+ "note_stem": "report",
+ "added": True,
+ "source_path": source.resolve(),
+ }
-def test_auto_resource_accepts_loose_root_resource():
+@pytest.mark.asyncio
+async def test_auto_resource_accepts_loose_root_resource():
"""Root-level resource files use today's date without moving the source."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- source = write_file(workspace / "resource" / "report.txt", "hello")
- today = datetime.datetime.now().strftime("%Y-%m-%d")
- captured = {}
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ source = write_file(workspace / "resource" / "report.txt", "hello")
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ captured = {}
- step = AutoTextResourceStep(app_context=app_ctx)
+ step = AutoTextResourceStep(app_context=app_ctx)
- async def fake_upsert(file_path, date_str, note_stem, created, source_path):
- captured.update(
- {
- "file_path": file_path,
- "date_str": date_str,
- "note_stem": note_stem,
- "created": created,
- "source_path": source_path,
- },
- )
- step.context.response.success = True
- step.context.response.answer = "ok"
+ async def fake_upsert(file_path, date_str, note_stem, created, source_path):
+ captured.update(
+ {
+ "file_path": file_path,
+ "date_str": date_str,
+ "note_stem": note_stem,
+ "created": created,
+ "source_path": source_path,
+ },
+ )
+ step.context.response.success = True
+ step.context.response.answer = "ok"
- step._handle_upsert = fake_upsert
+ step._handle_upsert = fake_upsert
- resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
+ resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- assert resp.success is True
- assert source.read_text(encoding="utf-8") == "hello"
- assert not (workspace / "resource" / today / "report.txt").exists()
- assert captured == {
- "file_path": "resource/report.txt",
- "date_str": today,
- "note_stem": "report",
- "created": True,
- "source_path": source.resolve(),
- }
- print("✓ test_auto_resource_accepts_loose_root_resource passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert source.read_text(encoding="utf-8") == "hello"
+ assert not (workspace / "resource" / today / "report.txt").exists()
+ assert captured == {
+ "file_path": "resource/report.txt",
+ "date_str": today,
+ "note_stem": "report",
+ "created": True,
+ "source_path": source.resolve(),
+ }
-def test_auto_resource_loose_root_resource_keeps_existing_dated_resource():
+@pytest.mark.asyncio
+async def test_auto_resource_loose_root_resource_keeps_existing_dated_resource():
"""Loose-resource compatibility does not overwrite dated resource files."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- today = datetime.datetime.now().strftime("%Y-%m-%d")
- write_file(workspace / "resource" / today / "report.txt", "existing")
- source = write_file(workspace / "resource" / "report.txt", "new")
- captured = {}
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ write_file(workspace / "resource" / today / "report.txt", "existing")
+ source = write_file(workspace / "resource" / "report.txt", "new")
+ captured = {}
- step = AutoTextResourceStep(app_context=app_ctx)
+ step = AutoTextResourceStep(app_context=app_ctx)
- async def fake_upsert(file_path, date_str, note_stem, created, source_path):
- captured.update(
- {
- "file_path": file_path,
- "date_str": date_str,
- "note_stem": note_stem,
- "created": created,
- "source_path": source_path,
- },
- )
- step.context.response.success = True
- step.context.response.answer = "ok"
+ async def fake_upsert(file_path, date_str, note_stem, created, source_path):
+ captured.update(
+ {
+ "file_path": file_path,
+ "date_str": date_str,
+ "note_stem": note_stem,
+ "created": created,
+ "source_path": source_path,
+ },
+ )
+ step.context.response.success = True
+ step.context.response.answer = "ok"
- step._handle_upsert = fake_upsert
+ step._handle_upsert = fake_upsert
- resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
+ resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- assert resp.success is True
- assert (workspace / "resource" / today / "report.txt").read_text(encoding="utf-8") == "existing"
- assert source.read_text(encoding="utf-8") == "new"
- assert captured == {
- "file_path": "resource/report.txt",
- "date_str": today,
- "note_stem": "report",
- "created": True,
- "source_path": source.resolve(),
- }
- print("✓ test_auto_resource_loose_root_resource_keeps_existing_dated_resource passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert (workspace / "resource" / today / "report.txt").read_text(encoding="utf-8") == "existing"
+ assert source.read_text(encoding="utf-8") == "new"
+ assert captured == {
+ "file_path": "resource/report.txt",
+ "date_str": today,
+ "note_stem": "report",
+ "created": True,
+ "source_path": source.resolve(),
+ }
-def test_auto_resource_modified_missing_note_uses_create_tools():
+@pytest.mark.asyncio
+async def test_auto_resource_modified_missing_note_uses_create_tools():
"""A modified resource with no daily note is treated as a create."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
- wrapper.on_reply = lambda *_: write_file(
- cwd / "daily" / "2026-01-01" / "report.md",
- "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
+ wrapper.on_reply = lambda *_: write_file(
+ cwd / "daily" / "2026-01-01" / "report.md",
+ "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
+ )
- assert resp.success is True
- assert resp.metadata["results"][0]["metadata"]["created"] is True
- assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/resource-summary.md"
- assert wrapper.kwargs["job_tools"] == ["write"]
- assert "The target file does not exist" in wrapper.inputs
- assert "Target note path: daily/2026-01-01/report.md" in wrapper.inputs
- assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
- assert (cwd / "daily" / "2026-01-01" / "resource-summary.md").exists()
- finally:
- await fs.close()
- print("✓ test_auto_resource_modified_missing_note_uses_create_tools passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["metadata"]["created"] is True
+ assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/resource-summary.md"
+ assert wrapper.kwargs["job_tools"] == ["write"]
+ assert "The target file does not exist" in wrapper.inputs
+ assert "Target note path: daily/2026-01-01/report.md" in wrapper.inputs
+ assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
+ assert (cwd / "daily" / "2026-01-01" / "resource-summary.md").exists()
+ finally:
+ await fs.close()
-def test_auto_resource_create_preserves_unowned_same_stem_note():
+@pytest.mark.asyncio
+async def test_auto_resource_create_preserves_unowned_same_stem_note():
"""A no-source same-stem note is user-owned and never used as the staging path."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- source = write_file(workspace / "resource" / "2026-01-01" / "report.txt", "resource body")
- user_note = write_file(
- workspace / "daily" / "2026-01-01" / "report.md",
- "---\nname: report\ndescription: private user note\n---\nkeep this body\n",
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ source = write_file(workspace / "resource" / "2026-01-01" / "report.txt", "resource body")
+ user_note = write_file(
+ workspace / "daily" / "2026-01-01" / "report.md",
+ "---\nname: report\ndescription: private user note\n---\nkeep this body\n",
+ )
+ original = user_note.read_bytes()
+
+ def write_allocated_target(inputs, _kwargs):
+ target_line = next(line for line in str(inputs).splitlines() if line.startswith("Target note path: "))
+ target_path = target_line.removeprefix("Target note path: ").strip()
+ assert target_path != "daily/2026-01-01/report.md"
+ write_file(
+ workspace / target_path,
+ "---\nname: generated-topic\ndescription: resource summary\n"
+ "source_resource: '[[resource/2026-01-01/report.txt]]'\n---\nsummary\n",
)
- original = user_note.read_bytes()
- def write_allocated_target(inputs, _kwargs):
- target_line = next(
- line for line in str(inputs).splitlines() if line.startswith("Target note path: ")
- )
- target_path = target_line.removeprefix("Target note path: ").strip()
- assert target_path != "daily/2026-01-01/report.md"
- write_file(
- workspace / target_path,
- "---\nname: generated-topic\ndescription: resource summary\n"
- "source_resource: '[[resource/2026-01-01/report.txt]]'\n---\nsummary\n",
- )
+ wrapper.on_reply = write_allocated_target
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- wrapper.on_reply = write_allocated_target
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
-
- result_meta = resp.metadata["results"][0]["metadata"]
- assert resp.success is True
- assert result_meta["created"] is True
- assert result_meta["path"] == "daily/2026-01-01/generated-topic.md"
- assert user_note.read_bytes() == original
- assert (workspace / result_meta["path"]).is_file()
- finally:
- await fs.close()
-
- asyncio.run(run())
+ result_meta = resp.metadata["results"][0]["metadata"]
+ assert resp.success is True
+ assert result_meta["created"] is True
+ assert result_meta["path"] == "daily/2026-01-01/generated-topic.md"
+ assert user_note.read_bytes() == original
+ assert (workspace / result_meta["path"]).is_file()
+ finally:
+ await fs.close()
-def test_auto_resource_reports_modified_when_post_write_lookup_fails():
+@pytest.mark.asyncio
+async def test_auto_resource_reports_modified_when_post_write_lookup_fails():
"""A text note written before post-processing failure remains reported as modified."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- file_store = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await file_store.start()
- _install_file_jobs(app_ctx, file_store)
- try:
- source = write_file(workspace / "resource/2026-01-01/report.txt", "resource body")
- wrapper.on_reply = lambda *_: write_file(
- workspace / "daily/2026-01-01/report.md",
- "---\nname: report\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nsummary\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=file_store, agent_wrapper=wrapper)
- list_resource_note = step._list_resource_note
- calls = 0
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ file_store = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await file_store.start()
+ _install_file_jobs(app_ctx, file_store)
+ try:
+ source = write_file(workspace / "resource/2026-01-01/report.txt", "resource body")
+ wrapper.on_reply = lambda *_: write_file(
+ workspace / "daily/2026-01-01/report.md",
+ "---\nname: report\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nsummary\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=file_store, agent_wrapper=wrapper)
+ list_resource_note = step._list_resource_note
+ calls = 0
- async def fail_second_lookup(day, file_path):
- nonlocal calls
- calls += 1
- if calls == 2:
- raise RuntimeError("daily_list failed after agent write")
- return await list_resource_note(day, file_path)
+ async def fail_second_lookup(day, file_path):
+ nonlocal calls
+ calls += 1
+ if calls == 2:
+ raise RuntimeError("daily_list failed after agent write")
+ return await list_resource_note(day, file_path)
- step._list_resource_note = fail_second_lookup
- response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
+ step._list_resource_note = fail_second_lookup
+ response = await step(RuntimeContext(changes=[{"change": "added", "path": str(source)}]))
- result = response.metadata["results"][0]
- assert response.success is False
- assert result["metadata"]["action"] == "failed"
- assert result["metadata"]["path"] == "daily/2026-01-01/report.md"
- assert result["metadata"]["created"] is True
- assert result["metadata"]["modified"] is True
- assert "daily_list failed after agent write" in result["metadata"]["error"]
- assert (workspace / "daily/2026-01-01/report.md").is_file()
- finally:
- await file_store.close()
-
- asyncio.run(run())
+ result = response.metadata["results"][0]
+ assert response.success is False
+ assert result["metadata"]["action"] == "failed"
+ assert result["metadata"]["path"] == "daily/2026-01-01/report.md"
+ assert result["metadata"]["created"] is True
+ assert result["metadata"]["modified"] is True
+ assert "daily_list failed after agent write" in result["metadata"]["error"]
+ assert (workspace / "daily/2026-01-01/report.md").is_file()
+ finally:
+ await file_store.close()
-def test_auto_resource_sanitizes_invalid_generated_name():
+@pytest.mark.asyncio
+async def test_auto_resource_sanitizes_invalid_generated_name():
"""Invalid LLM-suggested names are sanitized before renaming."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
- wrapper.on_reply = lambda *_: write_file(
- cwd / "daily" / "2026-01-01" / "report.md",
- "---\nname: bad/name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "added", "path": "resource/2026-01-01/report.txt"}]),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
+ wrapper.on_reply = lambda *_: write_file(
+ cwd / "daily" / "2026-01-01" / "report.md",
+ "---\nname: bad/name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "added", "path": "resource/2026-01-01/report.txt"}]),
+ )
- assert resp.success is True
- assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/bad-name.md"
- assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
- assert "name: bad-name" in (cwd / "daily" / "2026-01-01" / "bad-name.md").read_text(encoding="utf-8")
- finally:
- await fs.close()
- print("✓ test_auto_resource_sanitizes_invalid_generated_name passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/bad-name.md"
+ assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
+ assert "name: bad-name" in (cwd / "daily" / "2026-01-01" / "bad-name.md").read_text(encoding="utf-8")
+ finally:
+ await fs.close()
-def test_auto_resource_uniquifies_conflicting_generated_name():
+@pytest.mark.asyncio
+async def test_auto_resource_uniquifies_conflicting_generated_name():
"""Conflicting LLM-suggested names get a stable source-derived suffix."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
- write_file(
- cwd / "daily" / "2026-01-01" / "resource-summary.md",
- "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/other.txt]]'\n---\nother\n",
- )
- wrapper.on_reply = lambda *_: write_file(
- cwd / "daily" / "2026-01-01" / "report.md",
- "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "added", "path": "resource/2026-01-01/report.txt"}]),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello")
+ write_file(
+ cwd / "daily" / "2026-01-01" / "resource-summary.md",
+ "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/other.txt]]'\n---\nother\n",
+ )
+ wrapper.on_reply = lambda *_: write_file(
+ cwd / "daily" / "2026-01-01" / "report.md",
+ "---\nname: resource-summary\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "added", "path": "resource/2026-01-01/report.txt"}]),
+ )
- path = resp.metadata["results"][0]["metadata"]["path"]
- assert resp.success is True
- assert path.startswith("daily/2026-01-01/resource-summary--")
- assert path.endswith(".md")
- assert (cwd / path).exists()
- assert "name: resource-summary--" in (cwd / path).read_text(encoding="utf-8")
- assert (cwd / "daily" / "2026-01-01" / "resource-summary.md").exists()
- assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
- finally:
- await fs.close()
- print("✓ test_auto_resource_uniquifies_conflicting_generated_name passed")
-
- asyncio.run(run())
+ path = resp.metadata["results"][0]["metadata"]["path"]
+ assert resp.success is True
+ assert path.startswith("daily/2026-01-01/resource-summary--")
+ assert path.endswith(".md")
+ assert (cwd / path).exists()
+ assert "name: resource-summary--" in (cwd / path).read_text(encoding="utf-8")
+ assert (cwd / "daily" / "2026-01-01" / "resource-summary.md").exists()
+ assert not (cwd / "daily" / "2026-01-01" / "report.md").exists()
+ finally:
+ await fs.close()
-def test_auto_resource_update_finds_renamed_note_by_source_resource():
+@pytest.mark.asyncio
+async def test_auto_resource_update_finds_renamed_note_by_source_resource():
"""A modified resource updates the renamed note linked by source_resource."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello v2")
- write_file(
- cwd / "daily" / "2026-01-01" / "generated-name.md",
- "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nold body\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello v2")
+ write_file(
+ cwd / "daily" / "2026-01-01" / "generated-name.md",
+ "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nold body\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
+ )
- assert resp.success is True
- assert resp.metadata["results"][0]["metadata"]["created"] is False
- assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
- assert wrapper.kwargs["job_tools"] == ["read", "edit", "frontmatter_update", "write"]
- assert "Target note path: daily/2026-01-01/generated-name.md" in wrapper.inputs
- finally:
- await fs.close()
- print("✓ test_auto_resource_update_finds_renamed_note_by_source_resource passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["metadata"]["created"] is False
+ assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
+ assert wrapper.kwargs["job_tools"] == ["read", "edit", "frontmatter_update", "write"]
+ assert "Target note path: daily/2026-01-01/generated-name.md" in wrapper.inputs
+ finally:
+ await fs.close()
-def test_auto_resource_update_keeps_existing_renamed_path():
+@pytest.mark.asyncio
+async def test_auto_resource_update_keeps_existing_renamed_path():
"""Updates do not rename an already-renamed note from a fresh LLM suggestion."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello v2")
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "hello v2")
+ write_file(
+ cwd / "daily" / "2026-01-01" / "generated-name.md",
+ "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nold body\n",
+ )
+
+ def rewrite_frontmatter(*_args):
write_file(
cwd / "daily" / "2026-01-01" / "generated-name.md",
- "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nold body\n",
+ "---\nname: new-suggestion\nsource_resource: "
+ "'[[resource/2026-01-01/report.txt]]'\n---\nnew body\n",
)
- def rewrite_frontmatter(*_args):
- write_file(
- cwd / "daily" / "2026-01-01" / "generated-name.md",
- "---\nname: new-suggestion\nsource_resource: "
- "'[[resource/2026-01-01/report.txt]]'\n---\nnew body\n",
- )
+ wrapper.on_reply = rewrite_frontmatter
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
+ )
- wrapper.on_reply = rewrite_frontmatter
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
- )
-
- path = cwd / "daily" / "2026-01-01" / "generated-name.md"
- assert resp.success is True
- assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
- assert path.exists()
- text = path.read_text(encoding="utf-8")
- assert "name: generated-name" in text
- assert not (cwd / "daily" / "2026-01-01" / "new-suggestion.md").exists()
- finally:
- await fs.close()
- print("✓ test_auto_resource_update_keeps_existing_renamed_path passed")
-
- asyncio.run(run())
+ path = cwd / "daily" / "2026-01-01" / "generated-name.md"
+ assert resp.success is True
+ assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
+ assert path.exists()
+ text = path.read_text(encoding="utf-8")
+ assert "name: generated-name" in text
+ assert not (cwd / "daily" / "2026-01-01" / "new-suggestion.md").exists()
+ finally:
+ await fs.close()
-def test_auto_resource_reports_unmodified_when_agent_skips_existing_note():
+@pytest.mark.asyncio
+async def test_auto_resource_reports_unmodified_when_agent_skips_existing_note():
"""A modified event that leaves the note bytes unchanged reports modified=False."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- write_file(cwd / "resource" / "2026-01-01" / "report.txt", "same")
- write_file(
- cwd / "daily" / "2026-01-01" / "report.md",
- "---\nname: report\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
- )
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ write_file(cwd / "resource" / "2026-01-01" / "report.txt", "same")
+ write_file(
+ cwd / "daily" / "2026-01-01" / "report.md",
+ "---\nname: report\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(changes=[{"change": "modified", "path": "resource/2026-01-01/report.txt"}]),
+ )
- result_meta = resp.metadata["results"][0]["metadata"]
- assert resp.success is True
- assert result_meta["modified"] is False
- assert resp.metadata["modified"] is False
- finally:
- await fs.close()
- print("✓ test_auto_resource_reports_unmodified_when_agent_skips_existing_note passed")
-
- asyncio.run(run())
+ result_meta = resp.metadata["results"][0]["metadata"]
+ assert resp.success is True
+ assert result_meta["modified"] is False
+ assert resp.metadata["modified"] is False
+ finally:
+ await fs.close()
-def test_auto_resource_preserves_unowned_loose_root_same_stem_note():
+@pytest.mark.asyncio
+async def test_auto_resource_preserves_unowned_loose_root_same_stem_note():
"""Deleting a loose resource never claims an unowned same-stem user note."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- fs = LocalFileStore(name="test_store", embedding_store="")
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- today = datetime.datetime.now().strftime("%Y-%m-%d")
- note_path = write_file(workspace / "daily" / today / "report.md", "---\nname: report\n---\nbody\n")
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ note_path = write_file(workspace / "daily" / today / "report.md", "---\nname: report\n---\nbody\n")
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
- resp = await step(RuntimeContext(changes=[{"change": "deleted", "path": "resource/report.txt"}]))
+ resp = await step(RuntimeContext(changes=[{"change": "deleted", "path": "resource/report.txt"}]))
- assert resp.success is True
- assert resp.metadata["results"][0]["path"] == "resource/report.txt"
- result_meta = resp.metadata["results"][0]["metadata"]
- assert result_meta["action"] == "skipped"
- assert result_meta["reason"] == "resource_note_not_found"
- assert result_meta["modified"] is False
- assert resp.metadata["modified"] is False
- assert note_path.read_text(encoding="utf-8") == "---\nname: report\n---\nbody\n"
- finally:
- await fs.close()
- print("✓ test_auto_resource_preserves_unowned_loose_root_same_stem_note passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["path"] == "resource/report.txt"
+ result_meta = resp.metadata["results"][0]["metadata"]
+ assert result_meta["action"] == "skipped"
+ assert result_meta["reason"] == "resource_note_not_found"
+ assert result_meta["modified"] is False
+ assert resp.metadata["modified"] is False
+ assert note_path.read_text(encoding="utf-8") == "---\nname: report\n---\nbody\n"
+ finally:
+ await fs.close()
-def test_auto_resource_deletes_renamed_note_by_source_resource():
+@pytest.mark.asyncio
+async def test_auto_resource_deletes_renamed_note_by_source_resource():
"""Deleting a resource removes the note even after frontmatter-name rename."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- workspace = Path.cwd()
- app_ctx = _make_app_context(workspace)
- fs = LocalFileStore(name="test_store", embedding_store="")
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- note_path = write_file(
- workspace / "daily" / "2026-01-01" / "generated-name.md",
- "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
- )
- step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ workspace = Path.cwd()
+ app_ctx = _make_app_context(workspace)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ note_path = write_file(
+ workspace / "daily" / "2026-01-01" / "generated-name.md",
+ "---\nname: generated-name\nsource_resource: '[[resource/2026-01-01/report.txt]]'\n---\nbody\n",
+ )
+ step = AutoTextResourceStep(app_context=app_ctx, file_store=fs)
- resp = await step(
- RuntimeContext(changes=[{"change": "deleted", "path": "resource/2026-01-01/report.txt"}]),
- )
+ resp = await step(
+ RuntimeContext(changes=[{"change": "deleted", "path": "resource/2026-01-01/report.txt"}]),
+ )
- assert resp.success is True
- assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
- assert not note_path.exists()
- finally:
- await fs.close()
- print("✓ test_auto_resource_deletes_renamed_note_by_source_resource passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["metadata"]["path"] == "daily/2026-01-01/generated-name.md"
+ assert not note_path.exists()
+ finally:
+ await fs.close()
-def test_auto_memory_reports_modified_for_create_and_false_for_skip():
+@pytest.mark.asyncio
+async def test_auto_memory_reports_modified_for_create_and_false_for_skip():
"""AutoMemoryStep reports whether a daily note actually changed."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="", tag_index="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- today = datetime.datetime.now().strftime("%Y-%m-%d")
- wrapper.on_reply = lambda *_: write_file(
- cwd / "daily" / today / "memory.md",
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="", tag_index="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ wrapper.on_reply = lambda *_: write_file(
+ cwd / "daily" / today / "memory.md",
+ "---\nname: memory\nsession_id: s1\nsource_conversation: '[[session/dialog/s1.jsonl]]'\n---\nbody\n",
+ )
+ step = AutoMemoryStep(
+ app_context=app_ctx,
+ file_store=fs,
+ agent_wrapper=wrapper,
+ )
+ context = RuntimeContext(
+ messages=[{"name": "user", "role": "user", "content": "remember project detail"}],
+ session_id="s1",
+ )
+ resp = await step(context)
+ resp = resp or step.context.response
+
+ assert resp.success is True
+ assert resp.metadata["created"] is True
+ assert resp.metadata["modified"] is True
+ assert context["changes"] == [{"change": "added", "path": f"daily/{today}/memory.md"}]
+ assert "tags:" not in (cwd / "daily" / today / "memory.md").read_text(encoding="utf-8")
+
+ wrapper.on_reply = None
+ context = RuntimeContext(messages=[], session_id="s2")
+ resp = await step(context)
+ resp = resp or step.context.response
+
+ assert resp.success is True
+ assert resp.metadata["modified"] is False
+ assert resp.metadata["n_messages"] == 0
+ assert context["changes"] == []
+ finally:
+ await fs.close()
+
+
+@pytest.mark.asyncio
+async def test_auto_memory_uses_message_day_for_historical_create():
+ """AutoMemoryStep creates historical daily notes from message timestamps."""
+
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ historical_day = "2023-01-19"
+
+ def write_historical_note(inputs, _kwargs):
+ assert f"Today: {historical_day}" in inputs
+ assert f"date={historical_day}" in inputs
+ assert "injected_job_kwargs" not in _kwargs
+ write_file(
+ cwd / "daily" / historical_day / "memory.md",
"---\nname: memory\nsession_id: s1\n"
"source_conversation: '[[session/dialog/s1.jsonl]]'\n---\nbody\n",
)
- step = AutoMemoryStep(
- app_context=app_ctx,
- file_store=fs,
- agent_wrapper=wrapper,
- )
- context = RuntimeContext(
- messages=[{"name": "user", "role": "user", "content": "remember project detail"}],
+
+ wrapper.on_reply = write_historical_note
+ step = AutoMemoryStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(
+ messages=[
+ {
+ "name": "user",
+ "role": "user",
+ "content": "remember historical project detail",
+ "created_at": "2023-01-19T08:00:00",
+ },
+ ],
session_id="s1",
- )
- resp = await step(context)
- resp = resp or step.context.response
+ ),
+ )
+ resp = resp or step.context.response
- assert resp.success is True
- assert resp.metadata["created"] is True
- assert resp.metadata["modified"] is True
- assert context["changes"] == [{"change": "added", "path": f"daily/{today}/memory.md"}]
- assert "tags:" not in (cwd / "daily" / today / "memory.md").read_text(encoding="utf-8")
-
- wrapper.on_reply = None
- context = RuntimeContext(messages=[], session_id="s2")
- resp = await step(context)
- resp = resp or step.context.response
-
- assert resp.success is True
- assert resp.metadata["modified"] is False
- assert resp.metadata["n_messages"] == 0
- assert context["changes"] == []
- finally:
- await fs.close()
- print("✓ test_auto_memory_reports_modified_for_create_and_false_for_skip passed")
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["date"] == historical_day
+ assert resp.metadata["path"] == "daily/2023-01-19/memory.md"
+ assert (cwd / "daily" / historical_day / "memory.md").is_file()
+ finally:
+ await fs.close()
-def test_auto_memory_uses_message_day_for_historical_create():
- """AutoMemoryStep creates historical daily notes from message timestamps."""
-
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- historical_day = "2023-01-19"
-
- def write_historical_note(inputs, _kwargs):
- assert f"Today: {historical_day}" in inputs
- assert f"date={historical_day}" in inputs
- assert "injected_job_kwargs" not in _kwargs
- write_file(
- cwd / "daily" / historical_day / "memory.md",
- "---\nname: memory\nsession_id: s1\n"
- "source_conversation: '[[session/dialog/s1.jsonl]]'\n---\nbody\n",
- )
-
- wrapper.on_reply = write_historical_note
- step = AutoMemoryStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(
- messages=[
- {
- "name": "user",
- "role": "user",
- "content": "remember historical project detail",
- "created_at": "2023-01-19T08:00:00",
- },
- ],
- session_id="s1",
- ),
- )
- resp = resp or step.context.response
-
- assert resp.success is True
- assert resp.metadata["date"] == historical_day
- assert resp.metadata["path"] == "daily/2023-01-19/memory.md"
- assert (cwd / "daily" / historical_day / "memory.md").is_file()
- finally:
- await fs.close()
- print("✓ test_auto_memory_uses_message_day_for_historical_create passed")
-
- asyncio.run(run())
-
-
-def test_auto_memory_rejects_invalid_explicit_date_before_saving_session():
+@pytest.mark.asyncio
+async def test_auto_memory_rejects_invalid_explicit_date_before_saving_session():
"""Invalid explicit dates fail before writing session history."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- cwd = Path.cwd()
- app_ctx = _make_app_context(cwd)
- fs = LocalFileStore(name="test_store", embedding_store="")
- wrapper = _FakeAgentWrapper()
- await fs.start()
- _install_file_jobs(app_ctx, fs)
- try:
- step = AutoMemoryStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
- resp = await step(
- RuntimeContext(
- messages=[
- {
- "name": "user",
- "role": "user",
- "content": "remember historical project detail",
- "created_at": "2023-01-19T08:00:00",
- },
- ],
- session_id="s1",
- date="2023-01-19T08:00:00",
- ),
- )
- resp = resp or step.context.response
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ cwd = Path.cwd()
+ app_ctx = _make_app_context(cwd)
+ fs = LocalFileStore(name="test_store", embedding_store="")
+ wrapper = _FakeAgentWrapper()
+ await fs.start()
+ _install_file_jobs(app_ctx, fs)
+ try:
+ step = AutoMemoryStep(app_context=app_ctx, file_store=fs, agent_wrapper=wrapper)
+ resp = await step(
+ RuntimeContext(
+ messages=[
+ {
+ "name": "user",
+ "role": "user",
+ "content": "remember historical project detail",
+ "created_at": "2023-01-19T08:00:00",
+ },
+ ],
+ session_id="s1",
+ date="2023-01-19T08:00:00",
+ ),
+ )
+ resp = resp or step.context.response
- assert resp.success is False
- assert resp.answer == "Error: date must be YYYY-MM-DD"
- assert resp.metadata == {
- "date": "2023-01-19T08:00:00",
- "modified": False,
- "n_messages": 1,
- }
- assert not (cwd / "session" / "dialog" / "s1.jsonl").exists()
- assert not (cwd / "daily").exists()
- assert wrapper.inputs == ""
- finally:
- await fs.close()
- print("✓ test_auto_memory_rejects_invalid_explicit_date_before_saving_session passed")
-
- asyncio.run(run())
+ assert resp.success is False
+ assert resp.answer == "Error: date must be YYYY-MM-DD"
+ assert resp.metadata == {
+ "date": "2023-01-19T08:00:00",
+ "modified": False,
+ "n_messages": 1,
+ }
+ assert not (cwd / "session" / "dialog" / "s1.jsonl").exists()
+ assert not (cwd / "daily").exists()
+ assert wrapper.inputs == ""
+ finally:
+ await fs.close()
-def test_auto_resource_result_hook_is_optional_and_isolated():
+@pytest.mark.asyncio
+async def test_auto_resource_result_hook_is_optional_and_isolated():
"""AutoResourceStep optionally emits host result hooks without coupling."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir:
- app_ctx = _make_app_context(Path(tmpdir))
- step = AutoResourceStep(app_context=app_ctx)
- step.context = RuntimeContext()
- changes = [{"change": "added", "path": "resource/2026-01-01/file.md"}]
- results = [{"success": True, "path": "resource/2026-01-01/file.md"}]
+ with tempfile.TemporaryDirectory() as tmpdir:
+ app_ctx = _make_app_context(Path(tmpdir))
+ step = AutoResourceStep(app_context=app_ctx)
+ step.context = RuntimeContext()
+ changes = [{"change": "added", "path": "resource/2026-01-01/file.md"}]
+ results = [{"success": True, "path": "resource/2026-01-01/file.md"}]
- app_ctx.metadata = {}
- await step._emit_result_hook(changes=changes, results=results)
+ app_ctx.metadata = {}
+ await step._emit_result_hook(changes=changes, results=results)
- calls = []
+ calls = []
- async def hook(**kwargs):
- calls.append(kwargs)
+ async def hook(**kwargs):
+ calls.append(kwargs)
- app_ctx.metadata = {"qwenpaw_memory_result_hook": hook}
- step.context.response.metadata["modified"] = False
- await step._emit_result_hook(changes=changes, results=results)
- assert not calls
+ app_ctx.metadata = {"qwenpaw_memory_result_hook": hook}
+ step.context.response.metadata["modified"] = False
+ await step._emit_result_hook(changes=changes, results=results)
+ assert not calls
- step.context.response.metadata["modified"] = True
- await step._emit_result_hook(changes=changes, results=results)
- assert len(calls) == 1
- assert calls[0]["job_name"] == "auto_resource"
- assert calls[0]["response"] is step.context.response
- assert calls[0]["kwargs"] == {"changes": changes}
- assert calls[0]["metadata"] == {"results": results}
+ step.context.response.metadata["modified"] = True
+ await step._emit_result_hook(changes=changes, results=results)
+ assert len(calls) == 1
+ assert calls[0]["job_name"] == "auto_resource"
+ assert calls[0]["response"] is step.context.response
+ assert calls[0]["kwargs"] == {"changes": changes}
+ assert calls[0]["metadata"] == {"results": results}
- def failing_hook(**_kwargs):
- raise RuntimeError("boom")
+ def failing_hook(**_kwargs):
+ raise RuntimeError("boom")
- app_ctx.metadata = {"qwenpaw_memory_result_hook": failing_hook}
- await step._emit_result_hook(changes=changes, results=results)
-
- print("✓ test_auto_resource_result_hook_is_optional_and_isolated passed")
-
- asyncio.run(run())
+ app_ctx.metadata = {"qwenpaw_memory_result_hook": failing_hook}
+ await step._emit_result_hook(changes=changes, results=results)
# LogChangesStep
# ---------------------------------------------------------------------------
-def test_log_changes_step():
+@pytest.mark.asyncio
+async def test_log_changes_step():
"""LogChangesStep logs and reports count."""
- async def run():
- step = LogChangesStep()
- changes = [
- {"change": "added", "path": "/workspace/daily/note.md"},
- {"change": "deleted", "path": "/workspace/daily/old.md"},
- ]
- ctx = RuntimeContext(changes=changes)
- resp = await step(ctx)
- assert resp.success is True
- assert resp.metadata["count"] == 2
- print("✓ test_log_changes_step passed")
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== Background Steps Tests ===")
- # _watch_rules
- test_build_watch_rules_basic()
- test_build_watch_rules_multiple_suffixes()
- test_build_watch_rules_fallback_literal()
- test_match_file_suffix()
- test_match_file_no_suffix_filter()
- test_collect_existing_filters()
- # InitChangesStep
- test_clear_and_scan_flow_includes_jsonl_when_configured()
- test_scan_changes_initial_all_added()
- test_scan_changes_no_changes()
- test_scan_changes_detect_modify_delete()
- test_scan_changes_missing_dir_skipped()
- test_scan_changes_resource_dir()
- test_index_update_loop_init_dispatch_updates_store_across_batches()
- test_digest_watch_loop_init_dispatch_updates_named_catalog_and_logs()
- # WatchChangesStep
- test_watch_changes_default_low_power_timing()
- test_watch_changes_requires_stop_event()
- test_watch_changes_raises_no_valid_paths()
- test_watch_changes_filter_matches_rules()
- test_watch_changes_dispatch_steps_list()
- test_auto_resource_batch_deleted_changes()
- test_auto_resource_accepts_loose_root_resource()
- test_auto_resource_loose_root_resource_keeps_existing_dated_resource()
- test_auto_resource_modified_missing_note_uses_create_tools()
- test_auto_resource_sanitizes_invalid_generated_name()
- test_auto_resource_uniquifies_conflicting_generated_name()
- test_auto_resource_update_finds_renamed_note_by_source_resource()
- test_auto_resource_update_keeps_existing_renamed_path()
- test_auto_memory_uses_message_day_for_historical_create()
- test_auto_memory_rejects_invalid_explicit_date_before_saving_session()
- test_auto_resource_preserves_unowned_loose_root_same_stem_note()
- test_auto_resource_deletes_renamed_note_by_source_resource()
- test_auto_resource_result_hook_is_optional_and_isolated()
- # LogChangesStep
- test_log_changes_step()
- print("\n所有测试通过!")
+ step = LogChangesStep()
+ changes = [
+ {"change": "added", "path": "/workspace/daily/note.md"},
+ {"change": "deleted", "path": "/workspace/daily/old.md"},
+ ]
+ ctx = RuntimeContext(changes=changes)
+ resp = await step(ctx)
+ assert resp.success is True
+ assert resp.metadata["count"] == 2
diff --git a/tests/unit/test_base_component.py b/tests/unit/test_base_component.py
index df7e8ead..28a69133 100644
--- a/tests/unit/test_base_component.py
+++ b/tests/unit/test_base_component.py
@@ -406,31 +406,3 @@ def test_workspace_metadata_path_no_context():
def test_component_metadata_path():
comp = StubComponent()
assert comp.component_metadata_path.name == ComponentEnum.FILE_CHUNKER.value
-
-
-if __name__ == "__main__":
- print("\n=== BaseComponent Tests ===")
- test_dependency_getattr_raises()
- test_bind_returns_none_for_empty_name()
- test_bind_returns_dependency_placeholder()
- test_bind_rejects_base_component_type()
- test_bind_rejects_no_component_type()
- test_bind_with_default_factory()
- test_bind_optional_flag()
- test_dependencies_lists_unresolved()
- test_start_close_idempotent()
- test_restart()
- test_async_context_manager()
- test_close_closes_owned_when_parent_close_fails()
- test_resolve_standalone_optional_becomes_none()
- test_resolve_standalone_with_default_factory()
- test_resolve_standalone_required_no_factory_keeps_placeholder()
- test_owned_components_started_and_closed()
- test_resolve_from_context()
- test_resolve_from_context_optional_missing()
- test_resolve_from_context_required_missing_raises()
- test_workspace_path_no_context()
- test_to_workspace_relative_outside_workspace()
- test_workspace_metadata_path_no_context()
- test_component_metadata_path()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_common_steps.py b/tests/unit/test_common_steps.py
index b9ec3aa3..b63c5f69 100644
--- a/tests/unit/test_common_steps.py
+++ b/tests/unit/test_common_steps.py
@@ -2,11 +2,12 @@
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as _temp_chdir
import tempfile
import warnings
+import pytest
+
from reme.components.agent_wrapper import BaseAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.components.file_store import LocalFileStore
@@ -21,27 +22,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class _temp_chdir:
- """chdir to path for the duration of the block; restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self._old = None
-
- def __enter__(self):
- self._old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self._old)
-
-
-def _run(coro):
- """Run an async coroutine on a fresh isolated event loop."""
- asyncio.run(coro)
-
-
def _node(
path: str,
links: list[tuple[str, str | None]] | None = None,
@@ -73,103 +53,82 @@ def _edges(step) -> list[dict]:
return step.context.response.answer["edges"]
-def test_add_step_coerces_numeric_inputs():
+@pytest.mark.asyncio
+async def test_add_step_coerces_numeric_inputs():
"""add accepts numeric strings as numbers, not string concatenation."""
- async def run():
- step = AddStep()
- resp = await step(a="1", b="2.5")
- assert resp.success is True
- assert resp.answer == "3.5"
- assert resp.metadata["result"] == 3.5
- print("✓ test_add_step_coerces_numeric_inputs passed")
-
- _run(run())
+ step = AddStep()
+ resp = await step(a="1", b="2.5")
+ assert resp.success is True
+ assert resp.answer == "3.5"
+ assert resp.metadata["result"] == 3.5
-def test_add_step_rejects_invalid_inputs():
+@pytest.mark.asyncio
+async def test_add_step_rejects_invalid_inputs():
"""invalid add arguments should return a failed response instead of throwing or concatenating."""
- async def run():
- step = AddStep()
- resp = await step(a="one", b=2)
- assert resp.success is False
- assert "Invalid add arguments" in resp.answer
- print("✓ test_add_step_rejects_invalid_inputs passed")
-
- _run(run())
+ step = AddStep()
+ resp = await step(a="one", b=2)
+ assert resp.success is False
+ assert "Invalid add arguments" in resp.answer
-def test_python_execute_step_returns_printed_stdout(tmp_path):
+@pytest.mark.asyncio
+async def test_python_execute_step_returns_printed_stdout(tmp_path):
"""python_execute returns stdout as answer and runs under workspace_dir."""
- async def run():
- app_context = ApplicationContext(workspace_dir=str(tmp_path))
- step = PythonExecuteStep(app_context=app_context)
- resp = await step(code="from pathlib import Path\nprint(Path.cwd().name)")
- assert resp.success is True
- assert resp.answer == f"{tmp_path.name}\n"
- assert resp.metadata["returncode"] == 0
- assert resp.metadata["stderr"] == ""
- print("✓ test_python_execute_step_returns_printed_stdout passed")
-
- _run(run())
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ step = PythonExecuteStep(app_context=app_context)
+ resp = await step(code="from pathlib import Path\nprint(Path.cwd().name)")
+ assert resp.success is True
+ assert resp.answer == f"{tmp_path.name}\n"
+ assert resp.metadata["returncode"] == 0
+ assert resp.metadata["stderr"] == ""
-def test_python_execute_step_reports_stderr_on_failure():
+@pytest.mark.asyncio
+async def test_python_execute_step_reports_stderr_on_failure():
"""python_execute captures traceback stderr instead of throwing."""
- async def run():
- step = PythonExecuteStep()
- resp = await step(code='raise RuntimeError("boom")')
- assert resp.success is False
- assert resp.metadata["returncode"] != 0
- assert "RuntimeError: boom" in resp.answer
- assert "RuntimeError: boom" in resp.metadata["stderr"]
- print("✓ test_python_execute_step_reports_stderr_on_failure passed")
-
- _run(run())
+ step = PythonExecuteStep()
+ resp = await step(code='raise RuntimeError("boom")')
+ assert resp.success is False
+ assert resp.metadata["returncode"] != 0
+ assert "RuntimeError: boom" in resp.answer
+ assert "RuntimeError: boom" in resp.metadata["stderr"]
-def test_python_execute_step_times_out():
+@pytest.mark.asyncio
+async def test_python_execute_step_times_out():
"""python_execute converts subprocess timeout into a failed response."""
- async def run():
- step = PythonExecuteStep()
- resp = await step(code="import time\ntime.sleep(1)", python_timeout=0.01)
- assert resp.success is False
- assert resp.answer == "Python execution timed out after 0.01s"
- assert resp.metadata["python_timeout"] == 0.01
- print("✓ test_python_execute_step_times_out passed")
-
- _run(run())
+ step = PythonExecuteStep()
+ resp = await step(code="import time\ntime.sleep(1)", python_timeout=0.01)
+ assert resp.success is False
+ assert resp.answer == "Python execution timed out after 0.01s"
+ assert resp.metadata["python_timeout"] == 0.01
-def test_python_execute_step_accepts_legacy_timeout_name():
+@pytest.mark.asyncio
+async def test_python_execute_step_accepts_legacy_timeout_name():
"""Configs written before the rename keep their timeout through the legacy key."""
- async def run():
- step = PythonExecuteStep()
- resp = await step(code="import time\ntime.sleep(1)", timeout=0.01)
- assert resp.success is False
- assert resp.answer == "Python execution timed out after 0.01s"
- assert resp.metadata["python_timeout"] == 0.01
- print("✓ test_python_execute_step_accepts_legacy_timeout_name passed")
-
- _run(run())
+ step = PythonExecuteStep()
+ resp = await step(code="import time\ntime.sleep(1)", timeout=0.01)
+ assert resp.success is False
+ assert resp.answer == "Python execution timed out after 0.01s"
+ assert resp.metadata["python_timeout"] == 0.01
-def test_python_execute_step_rejects_invalid_timeout():
+@pytest.mark.asyncio
+async def test_python_execute_step_rejects_invalid_timeout():
"""A non-positive python_timeout fails the call instead of running unbounded."""
- async def run():
- step = PythonExecuteStep()
- resp = await step(code="print(1)", python_timeout=0)
- assert resp.success is False
- assert resp.answer == "python_timeout must be a positive number"
- print("✓ test_python_execute_step_rejects_invalid_timeout passed")
-
- _run(run())
+ step = PythonExecuteStep()
+ resp = await step(code="print(1)", python_timeout=0)
+ assert resp.success is False
+ assert resp.answer == "python_timeout must be a positive number"
class _FakeAgentWrapper(BaseAgentWrapper):
@@ -184,19 +143,16 @@ class _FakeAgentWrapper(BaseAgentWrapper):
return {"result": "ok"}
-def test_llm_demo_always_registers_add_tool():
+@pytest.mark.asyncio
+async def test_llm_demo_always_registers_add_tool():
"""LLM demo always passes the add job as a tool."""
- async def run():
- wrapper = _FakeAgentWrapper()
- step = LLMDemoStep()
- resp = await step(query="hello", agent_wrapper=wrapper)
- assert resp.success is True
- assert wrapper.last_kwargs["job_tools"] == ["add"]
- assert "job_tools" not in resp.metadata
- print("✓ test_llm_demo_always_registers_add_tool passed")
-
- _run(run())
+ wrapper = _FakeAgentWrapper()
+ step = LLMDemoStep()
+ resp = await step(query="hello", agent_wrapper=wrapper)
+ assert resp.success is True
+ assert wrapper.last_kwargs["job_tools"] == ["add"]
+ assert "job_tools" not in resp.metadata
def test_file_graph_health_reports_neo4j_cached_counts():
@@ -217,7 +173,6 @@ def test_file_graph_health_reports_neo4j_cached_counts():
assert status["n_nodes"] == 3
assert status["n_edges"] == 4
assert status["n_virtual"] == 1
- print("✓ test_file_graph_health_reports_neo4j_cached_counts passed")
# ===========================================================================
@@ -226,254 +181,214 @@ def test_file_graph_health_reports_neo4j_cached_counts():
# ===========================================================================
-def test_traverse_forward_depth_1():
+@pytest.mark.asyncio
+async def test_traverse_forward_depth_1():
"""depth=1 forward returns direct outbound neighbors."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("a.md", [("b.md", None), ("c.md", "intro")]),
- _node("b.md"),
- _node("c.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="a.md", direction="forward", depth=1)
- results = _edges(step)
- paths = {r["target"] for r in results}
- assert paths == {"b.md", "c.md"}
- c_edge = next(r for r in results if r["target"] == "c.md")
- assert c_edge["target_anchor"] == "intro"
- assert c_edge["source"] == "a.md"
- assert c_edge["depth"] == 1
- await store.close()
- print("✓ test_traverse_forward_depth_1 passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("a.md", [("b.md", None), ("c.md", "intro")]),
+ _node("b.md"),
+ _node("c.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="a.md", direction="forward", depth=1)
+ results = _edges(step)
+ paths = {r["target"] for r in results}
+ assert paths == {"b.md", "c.md"}
+ c_edge = next(r for r in results if r["target"] == "c.md")
+ assert c_edge["target_anchor"] == "intro"
+ assert c_edge["source"] == "a.md"
+ assert c_edge["depth"] == 1
+ await store.close()
-def test_traverse_normalizes_windows_seed_path():
+@pytest.mark.asyncio
+async def test_traverse_normalizes_windows_seed_path():
"""Windows-style seeds match the graph's portable POSIX path keys."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("topics/a.md", [("topics/b.md", None)]),
- _node("topics/b.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path=r"topics\a.md", direction="forward", depth=1)
- assert [edge["target"] for edge in _edges(step)] == ["topics/b.md"]
- await store.close()
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("topics/a.md", [("topics/b.md", None)]),
+ _node("topics/b.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path=r"topics\a.md", direction="forward", depth=1)
+ assert [edge["target"] for edge in _edges(step)] == ["topics/b.md"]
+ await store.close()
-def test_traverse_backward_returns_inlinks():
+@pytest.mark.asyncio
+async def test_traverse_backward_returns_inlinks():
"""direction=backward walks inbound edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("a.md", [("b.md", None)]),
- _node("c.md", [("b.md", None)]),
- _node("b.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="b.md", direction="backward", depth=1)
- results = _edges(step)
- assert {(r["source"], r["target"]) for r in results} == {
- ("a.md", "b.md"),
- ("c.md", "b.md"),
- }
- await store.close()
- print("✓ test_traverse_backward_returns_inlinks passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("a.md", [("b.md", None)]),
+ _node("c.md", [("b.md", None)]),
+ _node("b.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="b.md", direction="backward", depth=1)
+ results = _edges(step)
+ assert {(r["source"], r["target"]) for r in results} == {
+ ("a.md", "b.md"),
+ ("c.md", "b.md"),
+ }
+ await store.close()
-def test_traverse_depth_2_expands():
+@pytest.mark.asyncio
+async def test_traverse_depth_2_expands():
"""depth=2 traverses one hop beyond direct neighbors."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("a.md", [("b.md", None)]),
- _node("b.md", [("c.md", None)]),
- _node("c.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="a.md", direction="forward", depth=2)
- results = _edges(step)
- depth_map = {r["target"]: r["depth"] for r in results}
- assert depth_map.get("b.md") == 1
- assert depth_map.get("c.md") == 2
- await store.close()
- print("✓ test_traverse_depth_2_expands passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("a.md", [("b.md", None)]),
+ _node("b.md", [("c.md", None)]),
+ _node("c.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="a.md", direction="forward", depth=2)
+ results = _edges(step)
+ depth_map = {r["target"]: r["depth"] for r in results}
+ assert depth_map.get("b.md") == 1
+ assert depth_map.get("c.md") == 2
+ await store.close()
-def test_traverse_short_seed_yields_empty():
+@pytest.mark.asyncio
+async def test_traverse_short_seed_yields_empty():
"""A short (not relative to the workspace) seed isn't resolved anymore — BFS simply
finds no edges from a path that doesn't match any graph node."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("topics/Bob.md"),
- _node("people/Bob.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="Bob", direction="forward", depth=1)
- payload = _edges(step)
- # No error, just empty results because "Bob" isn't a graph key.
- assert payload == []
- await store.close()
- print("✓ test_traverse_short_seed_yields_empty passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("topics/Bob.md"),
+ _node("people/Bob.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="Bob", direction="forward", depth=1)
+ payload = _edges(step)
+ # No error, just empty results because "Bob" isn't a graph key.
+ assert payload == []
+ await store.close()
-def test_traverse_not_found_seed():
+@pytest.mark.asyncio
+async def test_traverse_not_found_seed():
"""A seed not in the graph returns an empty list (no error)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store([_node("a.md")])
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="topics/ghost.md", direction="forward", depth=1)
- payload = _edges(step)
- assert payload == []
- await store.close()
- print("✓ test_traverse_not_found_seed passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store([_node("a.md")])
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="topics/ghost.md", direction="forward", depth=1)
+ payload = _edges(step)
+ assert payload == []
+ await store.close()
-def test_traverse_both_directions():
+@pytest.mark.asyncio
+async def test_traverse_both_directions():
"""direction=both walks out- and in-bound; depth=1 returns one hop in each direction."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("upstream.md", [("center.md", None)]),
- _node("center.md", [("downstream.md", None)]),
- _node("downstream.md"),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- await step(path="center.md", direction="both", depth=1)
- results = _edges(step)
- assert {(r["source"], r["target"]) for r in results} == {
- ("upstream.md", "center.md"),
- ("center.md", "downstream.md"),
- }
- await store.close()
- print("✓ test_traverse_both_directions passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("upstream.md", [("center.md", None)]),
+ _node("center.md", [("downstream.md", None)]),
+ _node("downstream.md"),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ await step(path="center.md", direction="both", depth=1)
+ results = _edges(step)
+ assert {(r["source"], r["target"]) for r in results} == {
+ ("upstream.md", "center.md"),
+ ("center.md", "downstream.md"),
+ }
+ await store.close()
-def test_traverse_both_preserves_reciprocal_edge_directions():
+@pytest.mark.asyncio
+async def test_traverse_both_preserves_reciprocal_edge_directions():
"""Opposite wikilinks remain two distinct directed graph edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node("a.md", [("b.md", None)]),
- _node("b.md", [("a.md", None)]),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- response = await step(path="a.md", direction="both", depth=1)
- graph = TraverseGraph.model_validate(response.answer)
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node("a.md", [("b.md", None)]),
+ _node("b.md", [("a.md", None)]),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ response = await step(path="a.md", direction="both", depth=1)
+ graph = TraverseGraph.model_validate(response.answer)
- assert {(edge.source, edge.target) for edge in graph.edges} == {
- ("a.md", "b.md"),
- ("b.md", "a.md"),
- }
- assert response.metadata == {}
- await store.close()
-
- asyncio.run(run())
+ assert {(edge.source, edge.target) for edge in graph.edges} == {
+ ("a.md", "b.md"),
+ ("b.md", "a.md"),
+ }
+ assert response.metadata == {}
+ await store.close()
-def test_traverse_returns_frontmatter_and_unresolved_nodes():
+@pytest.mark.asyncio
+async def test_traverse_returns_frontmatter_and_unresolved_nodes():
"""Graph nodes expose labels and distinguish indexed files from dangling targets."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store(
- [
- _node(
- "a.md",
- [("missing.md", "details")],
- name="Alpha",
- description="Root note",
- ),
- ],
- )
- step = traverse_mod.TraverseStep(file_store=store)
- response = await step(path="a.md", direction="forward", depth=1)
- graph = TraverseGraph.model_validate(response.answer)
- nodes = {node.path: node for node in graph.nodes}
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store(
+ [
+ _node(
+ "a.md",
+ [("missing.md", "details")],
+ name="Alpha",
+ description="Root note",
+ ),
+ ],
+ )
+ step = traverse_mod.TraverseStep(file_store=store)
+ response = await step(path="a.md", direction="forward", depth=1)
+ graph = TraverseGraph.model_validate(response.answer)
+ nodes = {node.path: node for node in graph.nodes}
- assert graph.version == 1
- assert graph.seeds == ["a.md"]
- assert nodes["a.md"].model_dump() == {
- "id": "a.md",
- "path": "a.md",
- "name": "Alpha",
- "description": "Root note",
- "depth": 0,
- "indexed": True,
- }
- assert nodes["missing.md"].indexed is False
- assert nodes["missing.md"].depth == 1
- assert graph.edges[0].target_anchor == "details"
- await store.close()
-
- asyncio.run(run())
+ assert graph.version == 1
+ assert graph.seeds == ["a.md"]
+ assert nodes["a.md"].model_dump() == {
+ "id": "a.md",
+ "path": "a.md",
+ "name": "Alpha",
+ "description": "Root note",
+ "depth": 0,
+ "indexed": True,
+ }
+ assert nodes["missing.md"].indexed is False
+ assert nodes["missing.md"].depth == 1
+ assert graph.edges[0].target_anchor == "details"
+ await store.close()
-def test_traverse_depth_zero_returns_only_seed_nodes():
+@pytest.mark.asyncio
+async def test_traverse_depth_zero_returns_only_seed_nodes():
"""A zero-hop traversal is valid and emits no edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
- store = await _make_store([_node("a.md", [("b.md", None)]), _node("b.md")])
- step = traverse_mod.TraverseStep(file_store=store)
- response = await step(path="a.md", direction="forward", depth=0)
- graph = TraverseGraph.model_validate(response.answer)
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ store = await _make_store([_node("a.md", [("b.md", None)]), _node("b.md")])
+ step = traverse_mod.TraverseStep(file_store=store)
+ response = await step(path="a.md", direction="forward", depth=0)
+ graph = TraverseGraph.model_validate(response.answer)
- assert [node.path for node in graph.nodes] == ["a.md"]
- assert graph.edges == []
- await store.close()
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== traverse step tests ===")
- test_traverse_forward_depth_1()
- test_traverse_backward_returns_inlinks()
- test_traverse_depth_2_expands()
- test_traverse_short_seed_yields_empty()
- test_traverse_not_found_seed()
- test_traverse_both_directions()
- test_traverse_both_preserves_reciprocal_edge_directions()
- test_traverse_returns_frontmatter_and_unresolved_nodes()
- test_traverse_depth_zero_returns_only_seed_nodes()
- print("\n所有测试通过!")
+ assert [node.path for node in graph.nodes] == ["a.md"]
+ assert graph.edges == []
+ await store.close()
diff --git a/tests/unit/test_component_registry.py b/tests/unit/test_component_registry.py
index cf46ff5d..a2bb2df0 100644
--- a/tests/unit/test_component_registry.py
+++ b/tests/unit/test_component_registry.py
@@ -162,20 +162,3 @@ def test_application_registry_copy_remains_mutable():
reg.register(_DummyComponent, "runtime-component")
assert reg.get(ComponentEnum.FILE_CHUNKER, "runtime-component") is _DummyComponent
assert R.get(ComponentEnum.FILE_CHUNKER, "runtime-component") is None
-
-
-if __name__ == "__main__":
- print("\n=== ComponentRegistry Tests ===")
- test_register_direct_with_explicit_name()
- test_register_direct_defaults_to_class_name()
- test_register_decorator()
- test_register_rejects_missing_component_type()
- test_register_rejects_empty_name()
- test_register_rejects_non_class_non_string()
- test_get_all_returns_copy()
- test_get_all_unknown_type_returns_empty()
- test_get_nonexistent_returns_none()
- test_unregister_existing()
- test_unregister_missing_returns_false()
- test_clear()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_compressor_step.py b/tests/unit/test_compressor_step.py
index b2cbf721..e3b024cb 100644
--- a/tests/unit/test_compressor_step.py
+++ b/tests/unit/test_compressor_step.py
@@ -1,6 +1,6 @@
"""Unit tests for CompressorStep without LLM dependencies."""
-import asyncio
+import pytest
from agentscope.message import TextBlock, ThinkingBlock
from agentscope.model import ChatResponse
@@ -8,7 +8,8 @@ from agentscope.model import ChatResponse
from reme.steps.evolve.compressor import CompressorStep
-def test_response_text_handles_non_streaming_chat_response():
+@pytest.mark.asyncio
+async def test_response_text_handles_non_streaming_chat_response():
"""A plain ChatResponse must not crash the async-iteration check.
Regression: ``ChatResponse`` inherits agentscope's ``DictMixin`` whose
@@ -17,56 +18,49 @@ def test_response_text_handles_non_streaming_chat_response():
failed with ``KeyError: '__aiter__'``.
"""
- async def run():
- result = ChatResponse(
- content=[
- {"type": "thinking", "thinking": "internal reasoning"},
- {"type": "text", "text": "compressed "},
- {"type": "text", "text": "output"},
- ],
- is_last=True,
- )
+ result = ChatResponse(
+ content=[
+ {"type": "thinking", "thinking": "internal reasoning"},
+ {"type": "text", "text": "compressed "},
+ {"type": "text", "text": "output"},
+ ],
+ is_last=True,
+ )
- text = await CompressorStep._response_text(result) # pylint: disable=protected-access
+ text = await CompressorStep._response_text(result) # pylint: disable=protected-access
- assert text == "compressed output"
-
- asyncio.run(run())
+ assert text == "compressed output"
-def test_response_text_handles_pydantic_content_blocks():
+@pytest.mark.asyncio
+async def test_response_text_handles_pydantic_content_blocks():
"""Real LLM responses carry pydantic block models, not dicts.
Regression: ``block.get(...)`` crashed with ``'ThinkingBlock' object has
no attribute 'get'`` on responses from reasoning models.
"""
- async def run():
- result = ChatResponse(
- content=[
- ThinkingBlock(type="thinking", thinking="internal reasoning"),
- TextBlock(type="text", text="compressed output"),
- ],
- is_last=True,
- )
+ result = ChatResponse(
+ content=[
+ ThinkingBlock(type="thinking", thinking="internal reasoning"),
+ TextBlock(type="text", text="compressed output"),
+ ],
+ is_last=True,
+ )
- text = await CompressorStep._response_text(result) # pylint: disable=protected-access
+ text = await CompressorStep._response_text(result) # pylint: disable=protected-access
- assert text == "compressed output"
-
- asyncio.run(run())
+ assert text == "compressed output"
-def test_response_text_consumes_streaming_chunks_and_keeps_last():
+@pytest.mark.asyncio
+async def test_response_text_consumes_streaming_chunks_and_keeps_last():
"""An async generator of ChatResponses is drained and the last chunk wins."""
- async def run():
- async def stream():
- yield ChatResponse(content=[{"type": "text", "text": "partial"}], is_last=False)
- yield ChatResponse(content=[{"type": "text", "text": "final text"}], is_last=True)
+ async def stream():
+ yield ChatResponse(content=[{"type": "text", "text": "partial"}], is_last=False)
+ yield ChatResponse(content=[{"type": "text", "text": "final text"}], is_last=True)
- text = await CompressorStep._response_text(stream()) # pylint: disable=protected-access
+ text = await CompressorStep._response_text(stream()) # pylint: disable=protected-access
- assert text == "final text"
-
- asyncio.run(run())
+ assert text == "final text"
diff --git a/tests/unit/test_crud_steps.py b/tests/unit/test_crud_steps.py
index 3ee620b5..cddc6a1e 100644
--- a/tests/unit/test_crud_steps.py
+++ b/tests/unit/test_crud_steps.py
@@ -26,13 +26,15 @@ outside the workspace are rejected.
# pylint: disable=protected-access,redefined-builtin
-import asyncio
+from contextlib import chdir as temp_chdir
import os
import tempfile
import warnings
from pathlib import Path, PureWindowsPath
from types import SimpleNamespace
+import pytest
+
from reme.components import ApplicationContext
from reme.components.file_store import LocalFileStore
from reme.schema import FileNode
@@ -51,22 +53,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
async def _make_store(files: dict[str, str] | None = None) -> LocalFileStore:
"""LocalFileStore seeded with files on disk + registered in the graph."""
store = LocalFileStore(name="t", embedding_store="")
@@ -92,11 +78,6 @@ def _metadata(step) -> dict:
return step.context.response.metadata
-def _run(coro):
- """Run an async coroutine on a fresh isolated event loop."""
- asyncio.run(coro)
-
-
def _seed_md(workspace_dir: Path, rel: str, body: str) -> Path:
target = workspace_dir / rel
target.parent.mkdir(parents=True, exist_ok=True)
@@ -112,49 +93,44 @@ def _seed_md(workspace_dir: Path, rel: str, body: str) -> Path:
# -- stat ----------------------------------------------------------------
-def test_stat_indexed_file():
+@pytest.mark.asyncio
+async def test_stat_indexed_file():
"""stat reports size and mtime in its answer, plus metadata for an indexed file."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store({"topics/n.md": "---\nname: T\n---\nbody"})
- step = crud_stat.StatStep(file_store=store)
- await step(path="topics/n.md")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "file"
- assert payload["size"] == (Path(tmp) / "topics/n.md").stat().st_size
- assert step.context.response.answer == (
- f"stat: topics/n.md (file, {payload['size']} bytes, mtime {payload['mtime']})"
- )
- assert payload["mime"].startswith("text/")
- assert payload["frontmatter"] == {"name": "T"}
- await store.close()
- print("✓ test_stat_indexed_file passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store({"topics/n.md": "---\nname: T\n---\nbody"})
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="topics/n.md")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "file"
+ assert payload["size"] == (Path(tmp) / "topics/n.md").stat().st_size
+ assert step.context.response.answer == (
+ f"stat: topics/n.md (file, {payload['size']} bytes, mtime {payload['mtime']})"
+ )
+ assert payload["mime"].startswith("text/")
+ assert payload["frontmatter"] == {"name": "T"}
+ await store.close()
-def test_stat_directory_fallback():
+@pytest.mark.asyncio
+async def test_stat_directory_fallback():
"""stat on a non-indexed directory falls back to a plain join + type=dir."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- (Path(tmp) / "topics").mkdir(parents=True, exist_ok=True)
- step = crud_stat.StatStep(file_store=store)
- await step(path="topics")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "dir"
- assert step.context.response.answer == f"stat: topics (dir, mtime {payload['mtime']})"
- await store.close()
- print("✓ test_stat_directory_fallback passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ (Path(tmp) / "topics").mkdir(parents=True, exist_ok=True)
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="topics")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "dir"
+ assert step.context.response.answer == f"stat: topics (dir, mtime {payload['mtime']})"
+ await store.close()
-def test_stat_directory_wins_over_same_name_index():
+@pytest.mark.asyncio
+async def test_stat_directory_wins_over_same_name_index():
"""stat prefers an existing directory over its same-name ``.md`` index.
The daily layout keeps ``//`` and its
@@ -162,126 +138,110 @@ def test_stat_directory_wins_over_same_name_index():
must report the directory, while the explicit ``.md`` still hits the file.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "daily/2026-08-10/note.md": "---\nname: n\n---\nbody",
- "daily/2026-08-10.md": "---\nname: index\n---\nindex body",
- },
- )
- step = crud_stat.StatStep(file_store=store)
- await step(path="daily/2026-08-10")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "dir"
- assert payload["absolute_path"].endswith("2026-08-10")
- assert "resolved_path" not in payload
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "daily/2026-08-10/note.md": "---\nname: n\n---\nbody",
+ "daily/2026-08-10.md": "---\nname: index\n---\nindex body",
+ },
+ )
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="daily/2026-08-10")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "dir"
+ assert payload["absolute_path"].endswith("2026-08-10")
+ assert "resolved_path" not in payload
- step = crud_stat.StatStep(file_store=store)
- await step(path="daily/2026-08-10.md")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "file"
- assert payload["frontmatter"] == {"name": "index"}
- await store.close()
- print("✓ test_stat_directory_wins_over_same_name_index passed")
-
- asyncio.run(run())
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="daily/2026-08-10.md")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "file"
+ assert payload["frontmatter"] == {"name": "index"}
+ await store.close()
-def test_stat_shadowed_extensionless_reports_resolved_path():
+@pytest.mark.asyncio
+async def test_stat_shadowed_extensionless_reports_resolved_path():
"""When both ``LICENSE`` and ``LICENSE.md`` exist, ``stat path=LICENSE``
resolves to the ``.md`` but discloses the substitution via ``resolved_path``.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
- (Path(tmp) / "LICENSE.md").write_text("y" * 12, encoding="utf-8")
- store = await _make_store()
- step = crud_stat.StatStep(file_store=store)
- await step(path="LICENSE")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "file"
- assert payload["path"] == "LICENSE"
- assert payload["size"] == 12
- assert payload["mime"] == "text/markdown"
- assert payload["absolute_path"].endswith("LICENSE.md")
- assert payload["resolved_path"] == "LICENSE.md"
- await store.close()
- print("✓ test_stat_shadowed_extensionless_reports_resolved_path passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
+ (Path(tmp) / "LICENSE.md").write_text("y" * 12, encoding="utf-8")
+ store = await _make_store()
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="LICENSE")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "file"
+ assert payload["path"] == "LICENSE"
+ assert payload["size"] == 12
+ assert payload["mime"] == "text/markdown"
+ assert payload["absolute_path"].endswith("LICENSE.md")
+ assert payload["resolved_path"] == "LICENSE.md"
+ await store.close()
-def test_stat_extensionless_file_without_md_sibling():
+@pytest.mark.asyncio
+async def test_stat_extensionless_file_without_md_sibling():
"""An extension-less file with no ``.md`` sibling is reported as-is."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
- store = await _make_store()
- step = crud_stat.StatStep(file_store=store)
- await step(path="LICENSE")
- payload = _metadata(step)
- assert payload["exists"] is True
- assert payload["type"] == "file"
- assert payload["size"] == 15
- assert payload["absolute_path"].endswith("LICENSE")
- assert "resolved_path" not in payload
- await store.close()
- print("✓ test_stat_extensionless_file_without_md_sibling passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8")
+ store = await _make_store()
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="LICENSE")
+ payload = _metadata(step)
+ assert payload["exists"] is True
+ assert payload["type"] == "file"
+ assert payload["size"] == 15
+ assert payload["absolute_path"].endswith("LICENSE")
+ assert "resolved_path" not in payload
+ await store.close()
-def test_stat_missing_reports_probed_md_path():
+@pytest.mark.asyncio
+async def test_stat_missing_reports_probed_md_path():
"""stat on a missing suffix-less path reports the probed ``.md`` candidate."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- step = crud_stat.StatStep(file_store=store)
- await step(path="digest/missing")
- payload = _metadata(step)
- assert payload["exists"] is False
- assert payload["resolved_path"] == "digest/missing.md"
- assert "digest/missing.md" in str(step.context.response.answer)
- await store.close()
- print("✓ test_stat_missing_reports_probed_md_path passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ step = crud_stat.StatStep(file_store=store)
+ await step(path="digest/missing")
+ payload = _metadata(step)
+ assert payload["exists"] is False
+ assert payload["resolved_path"] == "digest/missing.md"
+ assert "digest/missing.md" in str(step.context.response.answer)
+ await store.close()
# -- list ----------------------------------------------------------------
-def test_list_lists_files():
+@pytest.mark.asyncio
+async def test_list_lists_files():
"""list returns paths relative to the workspace for files under the given directory."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "topics/a.md": "x",
- "topics/b.md": "y",
- "topics/sub/c.md": "z",
- },
- )
- step = crud_list.ListStep(file_store=store)
- await step(path="topics", recursive=True)
- payload = _metadata(step)
- assert set(payload["items"]) == {"topics/a.md", "topics/b.md", "topics/sub/c.md"}
- assert payload["count"] == 3
- answer = step.context.response.answer
- assert answer.startswith("Listed 3 file(s) under topics:\n")
- assert all(f"- {item}" in answer for item in payload["items"])
- await store.close()
- print("✓ test_list_lists_files passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "topics/a.md": "x",
+ "topics/b.md": "y",
+ "topics/sub/c.md": "z",
+ },
+ )
+ step = crud_list.ListStep(file_store=store)
+ await step(path="topics", recursive=True)
+ payload = _metadata(step)
+ assert set(payload["items"]) == {"topics/a.md", "topics/b.md", "topics/sub/c.md"}
+ assert payload["count"] == 3
+ answer = step.context.response.answer
+ assert answer.startswith("Listed 3 file(s) under topics:\n")
+ assert all(f"- {item}" in answer for item in payload["items"])
+ await store.close()
def test_list_formats_workspace_relative_windows_paths_as_posix():
@@ -292,44 +252,38 @@ def test_list_formats_workspace_relative_windows_paths_as_posix():
assert crud_list.ListStep._format_relative(files, workspace) == ["topics/a.md"]
-def test_list_empty_directory_has_explicit_answer():
+@pytest.mark.asyncio
+async def test_list_empty_directory_has_explicit_answer():
"""list tells an LLM explicitly when the target directory has no files."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- (Path(tmp) / "topics").mkdir()
- step = crud_list.ListStep(file_store=store)
- await step(path="topics")
- assert step.context.response.answer == "No files found under topics."
- assert _metadata(step) == {"items": [], "count": 0}
- await store.close()
- print("✓ test_list_empty_directory_has_explicit_answer passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ (Path(tmp) / "topics").mkdir()
+ step = crud_list.ListStep(file_store=store)
+ await step(path="topics")
+ assert step.context.response.answer == "No files found under topics."
+ assert _metadata(step) == {"items": [], "count": 0}
+ await store.close()
-def test_list_respects_limit_and_non_recursive():
+@pytest.mark.asyncio
+async def test_list_respects_limit_and_non_recursive():
"""Non-recursive list ignores subdirs; limit caps the count."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "topics/a.md": "x",
- "topics/b.md": "y",
- "topics/sub/c.md": "z",
- },
- )
- step = crud_list.ListStep(file_store=store)
- await step(path="topics", recursive=False, limit=1)
- payload = _metadata(step)
- assert payload["count"] == 1
- assert len(payload["items"]) == 1
- await store.close()
- print("✓ test_list_respects_limit_and_non_recursive passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "topics/a.md": "x",
+ "topics/b.md": "y",
+ "topics/sub/c.md": "z",
+ },
+ )
+ step = crud_list.ListStep(file_store=store)
+ await step(path="topics", recursive=False, limit=1)
+ payload = _metadata(step)
+ assert payload["count"] == 1
+ assert len(payload["items"]) == 1
+ await store.close()
def test_list_can_sort_by_most_recent_modification():
@@ -407,278 +361,244 @@ def test_list_mtime_sort_skips_files_deleted_during_scan(tmp_path):
# -- move ----------------------------------------------------------------
-def test_move_relocates_within_workspace():
+@pytest.mark.asyncio
+async def test_move_relocates_within_workspace():
"""move renames / relocates a file in place."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store({"daily/2026-05-18/foo/foo.md": "draft"})
- step = crud_move.MoveStep(file_store=store)
- await step(src_path="daily/2026-05-18/foo/foo.md", dst_path="knowledge/foo/foo.md")
- payload = _metadata(step)
- assert "error" not in payload
- assert not (Path(tmp) / "daily/2026-05-18/foo/foo.md").exists()
- assert (Path(tmp) / "knowledge/foo/foo.md").read_text(encoding="utf-8") == "draft"
- await store.close()
- print("✓ test_move_relocates_within_workspace passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store({"daily/2026-05-18/foo/foo.md": "draft"})
+ step = crud_move.MoveStep(file_store=store)
+ await step(src_path="daily/2026-05-18/foo/foo.md", dst_path="knowledge/foo/foo.md")
+ payload = _metadata(step)
+ assert "error" not in payload
+ assert not (Path(tmp) / "daily/2026-05-18/foo/foo.md").exists()
+ assert (Path(tmp) / "knowledge/foo/foo.md").read_text(encoding="utf-8") == "draft"
+ await store.close()
-def test_move_refuses_overwrite_without_flag():
+@pytest.mark.asyncio
+async def test_move_refuses_overwrite_without_flag():
"""move refuses to clobber an existing dst_path unless overwrite=True."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "a/x.md": "src",
- "b/x.md": "dst",
- },
- )
- step = crud_move.MoveStep(file_store=store)
- await step(src_path="a/x.md", dst_path="b/x.md")
- payload = _metadata(step)
- assert "destination exists" in payload.get("error", "")
- assert (Path(tmp) / "a/x.md").exists()
- await store.close()
- print("✓ test_move_refuses_overwrite_without_flag passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "a/x.md": "src",
+ "b/x.md": "dst",
+ },
+ )
+ step = crud_move.MoveStep(file_store=store)
+ await step(src_path="a/x.md", dst_path="b/x.md")
+ payload = _metadata(step)
+ assert "destination exists" in payload.get("error", "")
+ assert (Path(tmp) / "a/x.md").exists()
+ await store.close()
-def test_move_default_retargets_inbound_links():
+@pytest.mark.asyncio
+async def test_move_default_retargets_inbound_links():
"""move with retarget=True (default) rewrites inbound full-path [[src_path]] → [[dst_path]]."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "daily/2026-05-18/draft/draft.md": "spec",
- # only the literal full-path form is retargeted by design;
- # short / no-ext forms are intentionally left alone.
- "knowledge/notes/notes.md": (
- "see [[daily/2026-05-18/draft/draft.md]] and again " "[[daily/2026-05-18/draft/draft.md]] twice"
- ),
- },
- )
- step = crud_move.MoveStep(file_store=store)
- await step(
- src_path="daily/2026-05-18/draft/draft.md",
- dst_path="knowledge/draft/draft.md",
- )
- payload = _metadata(step)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "daily/2026-05-18/draft/draft.md": "spec",
+ # only the literal full-path form is retargeted by design;
+ # short / no-ext forms are intentionally left alone.
+ "knowledge/notes/notes.md": (
+ "see [[daily/2026-05-18/draft/draft.md]] and again " "[[daily/2026-05-18/draft/draft.md]] twice"
+ ),
+ },
+ )
+ step = crud_move.MoveStep(file_store=store)
+ await step(
+ src_path="daily/2026-05-18/draft/draft.md",
+ dst_path="knowledge/draft/draft.md",
+ )
+ payload = _metadata(step)
- assert "error" not in payload
- assert payload["retarget"]["files_touched"] == 1
- assert payload["retarget"]["links_changed"] == 2
+ assert "error" not in payload
+ assert payload["retarget"]["files_touched"] == 1
+ assert payload["retarget"]["links_changed"] == 2
- notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8")
- assert "[[daily/2026-05-18/draft/draft.md]]" not in notes
- assert notes.count("[[knowledge/draft/draft.md]]") == 2
- await store.close()
- print("✓ test_move_default_retargets_inbound_links passed")
-
- asyncio.run(run())
+ notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8")
+ assert "[[daily/2026-05-18/draft/draft.md]]" not in notes
+ assert notes.count("[[knowledge/draft/draft.md]]") == 2
+ await store.close()
-def test_move_opt_out_leaves_links_dangling():
+@pytest.mark.asyncio
+async def test_move_opt_out_leaves_links_dangling():
"""retarget=False moves the file but leaves inbound references stale."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "daily/2026-05-18/draft/draft.md": "spec",
- "knowledge/notes/notes.md": "see [[daily/2026-05-18/draft/draft.md]]",
- },
- )
- step = crud_move.MoveStep(file_store=store)
- await step(
- src_path="daily/2026-05-18/draft/draft.md",
- dst_path="knowledge/draft/draft.md",
- retarget=False,
- )
- payload = _metadata(step)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "daily/2026-05-18/draft/draft.md": "spec",
+ "knowledge/notes/notes.md": "see [[daily/2026-05-18/draft/draft.md]]",
+ },
+ )
+ step = crud_move.MoveStep(file_store=store)
+ await step(
+ src_path="daily/2026-05-18/draft/draft.md",
+ dst_path="knowledge/draft/draft.md",
+ retarget=False,
+ )
+ payload = _metadata(step)
- assert "error" not in payload
- assert payload["retarget"] is None
+ assert "error" not in payload
+ assert payload["retarget"] is None
- notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8")
- # link UNCHANGED — caller opted out of retarget
- assert "[[daily/2026-05-18/draft/draft.md]]" in notes
- await store.close()
- print("✓ test_move_opt_out_leaves_links_dangling passed")
-
- asyncio.run(run())
+ notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8")
+ # link UNCHANGED — caller opted out of retarget
+ assert "[[daily/2026-05-18/draft/draft.md]]" in notes
+ await store.close()
# -- delete --------------------------------------------------------------
-def test_delete_removes_file():
+@pytest.mark.asyncio
+async def test_delete_removes_file():
"""delete hard-removes the file."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store({"knowledge/draft/draft.md": "x"})
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="knowledge/draft/draft.md")
- payload = _metadata(step)
- assert payload.get("deleted") is True
- assert not (Path(tmp) / "knowledge/draft/draft.md").exists()
- await store.close()
- print("✓ test_delete_removes_file passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store({"knowledge/draft/draft.md": "x"})
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="knowledge/draft/draft.md")
+ payload = _metadata(step)
+ assert payload.get("deleted") is True
+ assert not (Path(tmp) / "knowledge/draft/draft.md").exists()
+ await store.close()
-def test_delete_missing_returns_error():
+@pytest.mark.asyncio
+async def test_delete_missing_returns_error():
"""delete on a nonexistent path returns error rather than raising."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="knowledge/nope/nope.md")
- payload = _metadata(step)
- assert payload["error"] == "not found"
- await store.close()
- print("✓ test_delete_missing_returns_error passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="knowledge/nope/nope.md")
+ payload = _metadata(step)
+ assert payload["error"] == "not found"
+ await store.close()
-def test_delete_reports_inbound_refs():
+@pytest.mark.asyncio
+async def test_delete_reports_inbound_refs():
"""delete returns the inbound wikilink list (literal full-path matches only)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "knowledge/target/target.md": "doomed",
- "knowledge/a/a.md": "see [[knowledge/target/target.md]]",
- "knowledge/b/b.md": (
- "ref [[knowledge/target/target.md]] and again " "[[knowledge/target/target.md]]"
- ),
- # short / no-ext forms are NOT counted by design
- "knowledge/c/c.md": "[[target]] and [[knowledge/target/target]]",
- },
- )
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="knowledge/target/target.md")
- payload = _metadata(step)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "knowledge/target/target.md": "doomed",
+ "knowledge/a/a.md": "see [[knowledge/target/target.md]]",
+ "knowledge/b/b.md": ("ref [[knowledge/target/target.md]] and again " "[[knowledge/target/target.md]]"),
+ # short / no-ext forms are NOT counted by design
+ "knowledge/c/c.md": "[[target]] and [[knowledge/target/target]]",
+ },
+ )
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="knowledge/target/target.md")
+ payload = _metadata(step)
- assert payload["deleted"] is True
- assert not (Path(tmp) / "knowledge/target/target.md").exists()
- # referencing files are untouched — agent decides what to do
- assert (Path(tmp) / "knowledge/a/a.md").read_text(encoding="utf-8") == (
- "see [[knowledge/target/target.md]]"
- )
+ assert payload["deleted"] is True
+ assert not (Path(tmp) / "knowledge/target/target.md").exists()
+ # referencing files are untouched — agent decides what to do
+ assert (Path(tmp) / "knowledge/a/a.md").read_text(encoding="utf-8") == ("see [[knowledge/target/target.md]]")
- inbound = payload["inbound"]
- paths = {item["path"] for item in inbound["by_file"]}
- assert paths == {"knowledge/a/a.md", "knowledge/b/b.md"}
- # a: 1 full-path ref; b: 2 full-path refs; c: not counted
- assert inbound["files_touched"] == 2
- assert inbound["links_total"] == 3
- await store.close()
- print("✓ test_delete_reports_inbound_refs passed")
-
- asyncio.run(run())
+ inbound = payload["inbound"]
+ paths = {item["path"] for item in inbound["by_file"]}
+ assert paths == {"knowledge/a/a.md", "knowledge/b/b.md"}
+ # a: 1 full-path ref; b: 2 full-path refs; c: not counted
+ assert inbound["files_touched"] == 2
+ assert inbound["links_total"] == 3
+ await store.close()
-def test_delete_folder_removes_tree():
+@pytest.mark.asyncio
+async def test_delete_folder_removes_tree():
"""delete on a directory hard-removes the whole subtree."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "scratch/a.md": "alpha",
- "scratch/sub/b.md": "beta",
- "scratch/asset.bin": "blob",
- "keeper/k.md": "kept",
- },
- )
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="scratch")
- payload = _metadata(step)
- assert payload["deleted"] is True
- assert payload["is_dir"] is True
- assert set(payload["deleted_files"]) == {
- "scratch/a.md",
- "scratch/sub/b.md",
- "scratch/asset.bin",
- }
- assert not (Path(tmp) / "scratch").exists()
- assert (Path(tmp) / "keeper/k.md").exists()
- await store.close()
- print("✓ test_delete_folder_removes_tree passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "scratch/a.md": "alpha",
+ "scratch/sub/b.md": "beta",
+ "scratch/asset.bin": "blob",
+ "keeper/k.md": "kept",
+ },
+ )
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="scratch")
+ payload = _metadata(step)
+ assert payload["deleted"] is True
+ assert payload["is_dir"] is True
+ assert set(payload["deleted_files"]) == {
+ "scratch/a.md",
+ "scratch/sub/b.md",
+ "scratch/asset.bin",
+ }
+ assert not (Path(tmp) / "scratch").exists()
+ assert (Path(tmp) / "keeper/k.md").exists()
+ await store.close()
-def test_delete_folder_reports_only_external_inbound():
+@pytest.mark.asyncio
+async def test_delete_folder_reports_only_external_inbound():
"""Inbound from inside the doomed folder is suppressed; outside refs surface."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store(
- {
- "doomed/a.md": "see [[doomed/b.md]]", # internal — filtered out
- "doomed/b.md": "see [[doomed/a.md]]", # internal — filtered out
- "outside/x.md": ("ext [[doomed/a.md]] and [[doomed/a.md]] plus [[doomed/b.md]]"),
- "outside/y.md": "another [[doomed/a.md]]",
- },
- )
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="doomed")
- payload = _metadata(step)
- assert payload["deleted"] is True
- assert payload["is_dir"] is True
- assert not (Path(tmp) / "doomed").exists()
- # outside files survive untouched
- assert (Path(tmp) / "outside/x.md").exists()
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store(
+ {
+ "doomed/a.md": "see [[doomed/b.md]]", # internal — filtered out
+ "doomed/b.md": "see [[doomed/a.md]]", # internal — filtered out
+ "outside/x.md": ("ext [[doomed/a.md]] and [[doomed/a.md]] plus [[doomed/b.md]]"),
+ "outside/y.md": "another [[doomed/a.md]]",
+ },
+ )
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="doomed")
+ payload = _metadata(step)
+ assert payload["deleted"] is True
+ assert payload["is_dir"] is True
+ assert not (Path(tmp) / "doomed").exists()
+ # outside files survive untouched
+ assert (Path(tmp) / "outside/x.md").exists()
- inbound = payload["inbound"]
- # external sources: x.md, y.md (deduped) → 2 files
- assert inbound["files_touched"] == 2
- # 2 refs to doomed/a.md from x + 1 ref from y + 1 ref to b from x = 4
- assert inbound["links_total"] == 4
+ inbound = payload["inbound"]
+ # external sources: x.md, y.md (deduped) → 2 files
+ assert inbound["files_touched"] == 2
+ # 2 refs to doomed/a.md from x + 1 ref from y + 1 ref to b from x = 4
+ assert inbound["links_total"] == 4
- by_target = {row["target"]: row for row in inbound["by_target"]}
- assert set(by_target) == {"doomed/a.md", "doomed/b.md"}
- a_sources = {row["path"]: row["count"] for row in by_target["doomed/a.md"]["by_file"]}
- assert a_sources == {"outside/x.md": 2, "outside/y.md": 1}
- b_sources = {row["path"]: row["count"] for row in by_target["doomed/b.md"]["by_file"]}
- assert b_sources == {"outside/x.md": 1}
- await store.close()
- print("✓ test_delete_folder_reports_only_external_inbound passed")
-
- asyncio.run(run())
+ by_target = {row["target"]: row for row in inbound["by_target"]}
+ assert set(by_target) == {"doomed/a.md", "doomed/b.md"}
+ a_sources = {row["path"]: row["count"] for row in by_target["doomed/a.md"]["by_file"]}
+ assert a_sources == {"outside/x.md": 2, "outside/y.md": 1}
+ b_sources = {row["path"]: row["count"] for row in by_target["doomed/b.md"]["by_file"]}
+ assert b_sources == {"outside/x.md": 1}
+ await store.close()
-def test_delete_folder_empty_has_no_inbound():
+@pytest.mark.asyncio
+async def test_delete_folder_empty_has_no_inbound():
"""Empty folder delete reports zero deleted files and zero inbound."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- (Path(tmp) / "empty").mkdir()
- store = await _make_store()
- step = crud_delete.DeleteStep(file_store=store)
- await step(path="empty")
- payload = _metadata(step)
- assert payload["deleted"] is True
- assert payload["is_dir"] is True
- assert payload["deleted_files"] == []
- assert payload["inbound"]["files_touched"] == 0
- assert payload["inbound"]["links_total"] == 0
- assert not (Path(tmp) / "empty").exists()
- await store.close()
- print("✓ test_delete_folder_empty_has_no_inbound passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ (Path(tmp) / "empty").mkdir()
+ store = await _make_store()
+ step = crud_delete.DeleteStep(file_store=store)
+ await step(path="empty")
+ payload = _metadata(step)
+ assert payload["deleted"] is True
+ assert payload["is_dir"] is True
+ assert payload["deleted_files"] == []
+ assert payload["inbound"]["files_touched"] == 0
+ assert payload["inbound"]["links_total"] == 0
+ assert not (Path(tmp) / "empty").exists()
+ await store.close()
# ===========================================================================
@@ -710,181 +630,155 @@ async def _edit(store: LocalFileStore, **kwargs):
return step.context.response
-def test_read_relative_path():
+@pytest.mark.asyncio
+async def test_read_relative_path():
"""`read path=Templates/Recipe.md` returns the file body from the workspace."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "# Recipe\n\nMix flour and water.\n"
- _seed_md(Path(tmp), "Templates/Recipe.md", body)
- store = await _make_store()
- resp = await _read(store, path="Templates/Recipe.md")
- assert resp.success is True
- assert "# Recipe" in str(resp.answer)
- assert "flour and water" in str(resp.answer)
- await store.close()
- print("✓ test_read_relative_path passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "# Recipe\n\nMix flour and water.\n"
+ _seed_md(Path(tmp), "Templates/Recipe.md", body)
+ store = await _make_store()
+ resp = await _read(store, path="Templates/Recipe.md")
+ assert resp.success is True
+ assert "# Recipe" in str(resp.answer)
+ assert "flour and water" in str(resp.answer)
+ await store.close()
-def test_read_no_suffix_autoappends_md():
+@pytest.mark.asyncio
+async def test_read_no_suffix_autoappends_md():
"""A bare path with no suffix auto-appends `.md`."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "Templates/Recipe.md", "auto-md\n")
- store = await _make_store()
- resp = await _read(store, path="Templates/Recipe")
- assert resp.success is True
- assert "auto-md" in str(resp.answer)
- await store.close()
- print("✓ test_read_no_suffix_autoappends_md passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "Templates/Recipe.md", "auto-md\n")
+ store = await _make_store()
+ resp = await _read(store, path="Templates/Recipe")
+ assert resp.success is True
+ assert "auto-md" in str(resp.answer)
+ await store.close()
-def test_read_line_range():
+@pytest.mark.asyncio
+async def test_read_line_range():
"""start_line / end_line slice the file 1-based, inclusive."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "Notes.md", "L1\nL2\nL3\nL4\nL5\n")
- store = await _make_store()
- resp = await _read(store, path="Notes.md", start_line=2, end_line=4)
- assert resp.success is True
- assert "L2" in str(resp.answer)
- assert "L3" in str(resp.answer)
- assert "L4" in str(resp.answer)
- assert "L1" not in str(resp.answer)
- assert "L5" not in str(resp.answer)
- await store.close()
- print("✓ test_read_line_range passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "Notes.md", "L1\nL2\nL3\nL4\nL5\n")
+ store = await _make_store()
+ resp = await _read(store, path="Notes.md", start_line=2, end_line=4)
+ assert resp.success is True
+ assert "L2" in str(resp.answer)
+ assert "L3" in str(resp.answer)
+ assert "L4" in str(resp.answer)
+ assert "L1" not in str(resp.answer)
+ assert "L5" not in str(resp.answer)
+ await store.close()
-def test_read_absolute_path_inside_workspace_accepted():
+@pytest.mark.asyncio
+async def test_read_absolute_path_inside_workspace_accepted():
"""Absolute paths that resolve inside the workspace are accepted."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- target = _seed_md(Path(tmp), "Abs.md", "x\n")
- store = await _make_store()
- resp = await _read(store, path=str(target.resolve()))
- assert resp.success is True
- assert "x" in str(resp.answer)
- await store.close()
- print("✓ test_read_absolute_path_inside_workspace_accepted passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ target = _seed_md(Path(tmp), "Abs.md", "x\n")
+ store = await _make_store()
+ resp = await _read(store, path=str(target.resolve()))
+ assert resp.success is True
+ assert "x" in str(resp.answer)
+ await store.close()
-def test_file_io_ops_reject_outside_workspace_path():
+@pytest.mark.asyncio
+async def test_file_io_ops_reject_outside_workspace_path():
"""CRUD steps reject paths outside the workspace without side effects."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- outside_path = "/etc/passwd"
- _seed_md(Path(tmp), "target.md", "body\n")
- store = await _make_store({"target.md": "body\n"})
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ outside_path = "/etc/passwd"
+ _seed_md(Path(tmp), "target.md", "body\n")
+ store = await _make_store({"target.md": "body\n"})
- write_resp = await _write(store, path=outside_path, content="evil")
- assert write_resp.success is False
+ write_resp = await _write(store, path=outside_path, content="evil")
+ assert write_resp.success is False
- edit_resp = await _edit(store, path=outside_path, old_string="root", new_string="evil")
- assert edit_resp.success is False
+ edit_resp = await _edit(store, path=outside_path, old_string="root", new_string="evil")
+ assert edit_resp.success is False
- read_resp = await _read(store, path=outside_path)
- assert read_resp.success is False
+ read_resp = await _read(store, path=outside_path)
+ assert read_resp.success is False
- stat_step = crud_stat.StatStep(file_store=store)
- await stat_step(path=outside_path)
- assert stat_step.context.response.success is False
+ stat_step = crud_stat.StatStep(file_store=store)
+ await stat_step(path=outside_path)
+ assert stat_step.context.response.success is False
- list_step = crud_list.ListStep(file_store=store)
- await list_step(path=outside_path)
- assert list_step.context.response.success is False
+ list_step = crud_list.ListStep(file_store=store)
+ await list_step(path=outside_path)
+ assert list_step.context.response.success is False
- delete_step = crud_delete.DeleteStep(file_store=store)
- await delete_step(path=outside_path)
- assert delete_step.context.response.success is False
- assert (Path(tmp) / "target.md").exists()
+ delete_step = crud_delete.DeleteStep(file_store=store)
+ await delete_step(path=outside_path)
+ assert delete_step.context.response.success is False
+ assert (Path(tmp) / "target.md").exists()
- move_step = crud_move.MoveStep(file_store=store)
- await move_step(src_path=outside_path, dst_path="moved.md")
- assert move_step.context.response.success is False
+ move_step = crud_move.MoveStep(file_store=store)
+ await move_step(src_path=outside_path, dst_path="moved.md")
+ assert move_step.context.response.success is False
- await store.close()
- print("✓ test_file_io_ops_reject_outside_workspace_path passed")
-
- _run(run())
+ await store.close()
-def test_read_non_md_degraded():
+@pytest.mark.asyncio
+async def test_read_non_md_degraded():
"""Paths whose suffix is not `.md` are read in compatibility mode."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "data/foo.txt", "plain-text body\n")
- store = await _make_store()
- resp = await _read(store, path="data/foo.txt")
- assert resp.success is True
- assert "plain-text body" in str(resp.answer)
- await store.close()
- print("✓ test_read_non_md_degraded passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "data/foo.txt", "plain-text body\n")
+ store = await _make_store()
+ resp = await _read(store, path="data/foo.txt")
+ assert resp.success is True
+ assert "plain-text body" in str(resp.answer)
+ await store.close()
-def test_read_missing_file():
+@pytest.mark.asyncio
+async def test_read_missing_file():
"""Reading a non-existent file should fail with a clear error."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _read(store, path="NotThere.md")
- assert resp.success is False
- assert "does not exist" in str(resp.answer).lower()
- await store.close()
- print("✓ test_read_missing_file passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _read(store, path="NotThere.md")
+ assert resp.success is False
+ assert "does not exist" in str(resp.answer).lower()
+ await store.close()
-def test_read_start_after_end():
+@pytest.mark.asyncio
+async def test_read_start_after_end():
"""start_line > end_line is invalid."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "Range.md", "a\nb\nc\n")
- store = await _make_store()
- resp = await _read(store, path="Range.md", start_line=3, end_line=1)
- assert resp.success is False
- assert "start_line" in str(resp.answer)
- await store.close()
- print("✓ test_read_start_after_end passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "Range.md", "a\nb\nc\n")
+ store = await _make_store()
+ resp = await _read(store, path="Range.md", start_line=3, end_line=1)
+ assert resp.success is False
+ assert "start_line" in str(resp.answer)
+ await store.close()
-def test_read_start_line_exceeds_total():
+@pytest.mark.asyncio
+async def test_read_start_line_exceeds_total():
"""start_line beyond total line count is invalid."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "Short.md", "only-one-line\n")
- store = await _make_store()
- resp = await _read(store, path="Short.md", start_line=99)
- assert resp.success is False
- assert "exceeds" in str(resp.answer).lower()
- await store.close()
- print("✓ test_read_start_line_exceeds_total passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "Short.md", "only-one-line\n")
+ store = await _make_store()
+ resp = await _read(store, path="Short.md", start_line=99)
+ assert resp.success is False
+ assert "exceeds" in str(resp.answer).lower()
+ await store.close()
-def test_read_trailing_newline_does_not_inflate_total_lines():
+@pytest.mark.asyncio
+async def test_read_trailing_newline_does_not_inflate_total_lines():
"""A trailing '\\n' must not count as an extra line (small-file path).
``line1\\nline2\\nline3\\n`` is 3 lines, matching both the large-file
@@ -894,583 +788,431 @@ def test_read_trailing_newline_does_not_inflate_total_lines():
silently accepted instead of rejected.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_md(Path(tmp), "Short.md", "line1\nline2\nline3\n")
- store = await _make_store()
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_md(Path(tmp), "Short.md", "line1\nline2\nline3\n")
+ store = await _make_store()
- resp = await _read(store, path="Short.md", start_line=99)
- assert resp.success is False
- assert "exceeds file length (3 lines)" in str(resp.answer)
+ resp = await _read(store, path="Short.md", start_line=99)
+ assert resp.success is False
+ assert "exceeds file length (3 lines)" in str(resp.answer)
- past_eof = await _read(store, path="Short.md", start_line=4)
- assert past_eof.success is False
- assert "exceeds file length (3 lines)" in str(past_eof.answer)
+ past_eof = await _read(store, path="Short.md", start_line=4)
+ assert past_eof.success is False
+ assert "exceeds file length (3 lines)" in str(past_eof.answer)
- in_range = await _read(store, path="Short.md", start_line=3)
- assert in_range.success is True
- assert str(in_range.answer).strip() == "line3"
+ in_range = await _read(store, path="Short.md", start_line=3)
+ assert in_range.success is True
+ assert str(in_range.answer).strip() == "line3"
- await store.close()
- print("✓ test_read_trailing_newline_does_not_inflate_total_lines passed")
-
- _run(run())
+ await store.close()
-def test_read_truncation():
+@pytest.mark.asyncio
+async def test_read_truncation():
"""A file larger than DEFAULT_MAX_BYTES triggers truncation with a continuation notice."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- # Seed > DEFAULT_MAX_BYTES (50 KiB) so the default truncation kicks in.
- body = "\n".join(f"line {i}" for i in range(8000)) + "\n"
- _seed_md(Path(tmp), "Big.md", body)
- store = await _make_store()
- resp = await _read(store, path="Big.md")
- assert resp.success is True
- assert "truncated" in str(resp.answer)
- assert "start_line=" in str(resp.answer)
- await store.close()
- print("✓ test_read_truncation passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ # Seed > DEFAULT_MAX_BYTES (50 KiB) so the default truncation kicks in.
+ body = "\n".join(f"line {i}" for i in range(8000)) + "\n"
+ _seed_md(Path(tmp), "Big.md", body)
+ store = await _make_store()
+ resp = await _read(store, path="Big.md")
+ assert resp.success is True
+ assert "truncated" in str(resp.answer)
+ assert "start_line=" in str(resp.answer)
+ await store.close()
-def test_read_empty_path_rejected():
+@pytest.mark.asyncio
+async def test_read_empty_path_rejected():
"""An empty `path` should be rejected."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _read(store, path="")
- assert resp.success is False
- assert "required" in str(resp.answer).lower()
- await store.close()
- print("✓ test_read_empty_path_rejected passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _read(store, path="")
+ assert resp.success is False
+ assert "required" in str(resp.answer).lower()
+ await store.close()
-def test_read_format_session_injected_false_disables_yaml_true():
+@pytest.mark.asyncio
+async def test_read_format_session_injected_false_disables_yaml_true():
"""Runtime-injected ``read_step_format_session=False`` takes precedence
over a YAML-true config — existence check, not truthiness."""
- async def run():
- from agentscope.message import Msg
+ from agentscope.message import Msg
- m = Msg(name="user", role="user", content=[{"type": "text", "text": "hello world"}])
- jsonl_body = m.model_dump_json() + "\n"
+ m = Msg(name="user", role="user", content=[{"type": "text", "text": "hello world"}])
+ jsonl_body = m.model_dump_json() + "\n"
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- session_path = Path(tmp) / "session" / "dialog" / "s.jsonl"
- session_path.parent.mkdir(parents=True, exist_ok=True)
- session_path.write_text(jsonl_body, encoding="utf-8")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ session_path = Path(tmp) / "session" / "dialog" / "s.jsonl"
+ session_path.parent.mkdir(parents=True, exist_ok=True)
+ session_path.write_text(jsonl_body, encoding="utf-8")
- store = await _make_store()
+ store = await _make_store()
- # YAML config read_step_format_session=True, no injection → formatting active.
- step = crud_read.ReadStep(file_store=store, read_step_format_session=True)
- await step(path="session/dialog/s.jsonl")
- assert "[user @" in str(step.context.response.answer)
+ # YAML config read_step_format_session=True, no injection → formatting active.
+ step = crud_read.ReadStep(file_store=store, read_step_format_session=True)
+ await step(path="session/dialog/s.jsonl")
+ assert "[user @" in str(step.context.response.answer)
- # Same YAML config, but runtime injects False → must disable formatting.
- step = crud_read.ReadStep(file_store=store, read_step_format_session=True)
- await step(path="session/dialog/s.jsonl", read_step_format_session=False)
- assert "[user @" not in str(step.context.response.answer)
+ # Same YAML config, but runtime injects False → must disable formatting.
+ step = crud_read.ReadStep(file_store=store, read_step_format_session=True)
+ await step(path="session/dialog/s.jsonl", read_step_format_session=False)
+ assert "[user @" not in str(step.context.response.answer)
- await store.close()
- print("✓ test_read_format_session_injected_false_disables_yaml_true passed")
-
- _run(run())
+ await store.close()
-def test_read_format_session_with_non_normalized_session_dir():
+@pytest.mark.asyncio
+async def test_read_format_session_with_non_normalized_session_dir():
"""Session formatting recognizes a normalized target when config contains ``./``."""
- async def run():
- from agentscope.message import Msg
+ from agentscope.message import Msg
- m = Msg(name="user", role="user", content=[{"type": "text", "text": "hello world"}])
- jsonl_body = m.model_dump_json() + "\n"
+ m = Msg(name="user", role="user", content=[{"type": "text", "text": "hello world"}])
+ jsonl_body = m.model_dump_json() + "\n"
- with tempfile.TemporaryDirectory() as tmp:
- workspace = Path(tmp)
- session_path = workspace / "sessions" / "dialog" / "s.jsonl"
- session_path.parent.mkdir(parents=True, exist_ok=True)
- session_path.write_text(jsonl_body, encoding="utf-8")
+ with tempfile.TemporaryDirectory() as tmp:
+ workspace = Path(tmp)
+ session_path = workspace / "sessions" / "dialog" / "s.jsonl"
+ session_path.parent.mkdir(parents=True, exist_ok=True)
+ session_path.write_text(jsonl_body, encoding="utf-8")
- app_context = ApplicationContext(workspace_dir=str(workspace.resolve()), session_dir="./sessions")
- step = crud_read.ReadStep(app_context=app_context, read_step_format_session=True)
- step.file_store = SimpleNamespace(workspace_path=workspace)
- await step(path="sessions/dialog/s.jsonl")
+ app_context = ApplicationContext(workspace_dir=str(workspace.resolve()), session_dir="./sessions")
+ step = crud_read.ReadStep(app_context=app_context, read_step_format_session=True)
+ step.file_store = SimpleNamespace(workspace_path=workspace)
+ await step(path="sessions/dialog/s.jsonl")
- assert "[user @" in str(step.context.response.answer)
-
- _run(run())
+ assert "[user @" in str(step.context.response.answer)
# -- write / edit --------------------------------------------------------
-def test_write_basic_with_frontmatter():
+@pytest.mark.asyncio
+async def test_write_basic_with_frontmatter():
"""`write path=... name=... description=... content=...` writes a YAML front matter block."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _write(
- store,
- path="Notes/A.md",
- name="Greetings",
- description="a friendly hello note",
- content="# Hello",
- )
- assert resp.success is True
- assert "Wrote" in str(resp.answer)
- on_disk = (Path(tmp) / "Notes/A.md").read_text(encoding="utf-8")
- assert on_disk.startswith("---\n"), on_disk
- assert "name: Greetings" in on_disk
- assert "description: a friendly hello note" in on_disk
- assert "# Hello" in on_disk
- await store.close()
- print("✓ test_write_basic_with_frontmatter passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _write(
+ store,
+ path="Notes/A.md",
+ name="Greetings",
+ description="a friendly hello note",
+ content="# Hello",
+ )
+ assert resp.success is True
+ assert "Wrote" in str(resp.answer)
+ on_disk = (Path(tmp) / "Notes/A.md").read_text(encoding="utf-8")
+ assert on_disk.startswith("---\n"), on_disk
+ assert "name: Greetings" in on_disk
+ assert "description: a friendly hello note" in on_disk
+ assert "# Hello" in on_disk
+ await store.close()
-def test_write_rejects_invalid_path_components():
+@pytest.mark.asyncio
+async def test_write_rejects_invalid_path_components():
"""`resolve_path` validates each segment with the same rules as daily-note slugs:
Windows reserved chars / device names / trailing-dot (also blocks `..` traversal)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- for bad in (
- "CON.md", # Windows-reserved device name (with extension)
- "Notes/AUX", # device name in a sub-segment
- "Notes/foo str:
from reme.steps.evolve import now
@@ -108,338 +93,302 @@ async def _make_daily_write_step(store: LocalFileStore, workspace_dir: str):
# -- daily_list_step ----------------------------------------------------------
-def test_daily_list_default_date_is_today():
+@pytest.mark.asyncio
+async def test_daily_list_default_date_is_today():
"""No ``date`` arg ⇒ falls back to today; only today's notes returned."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- (_today(), "today-a", "today a"),
- (_today(), "today-b", "today b"),
- ("2026-05-17", "yesterday", "y"),
- ],
- )
- app_context = ApplicationContext(workspace_dir=tmp)
- app_context.components[ComponentEnum.FILE_STORE] = {"default": store}
- step = daily_list_step.DailyListStep(file_store=store, app_context=app_context)
- await step()
- payload = _metadata(step)
- assert payload["date"] == _today()
- assert payload["count"] == 2
- answer = step.context.response.answer
- assert f"daily/{_today()}/today-a.md" in answer
- assert f"daily/{_today()}/today-b.md" in answer
- await store.close()
- print("✓ test_daily_list_default_date_is_today passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ (_today(), "today-a", "today a"),
+ (_today(), "today-b", "today b"),
+ ("2026-05-17", "yesterday", "y"),
+ ],
+ )
+ app_context = ApplicationContext(workspace_dir=tmp)
+ app_context.components[ComponentEnum.FILE_STORE] = {"default": store}
+ step = daily_list_step.DailyListStep(file_store=store, app_context=app_context)
+ await step()
+ payload = _metadata(step)
+ assert payload["date"] == _today()
+ assert payload["count"] == 2
+ answer = step.context.response.answer
+ assert f"daily/{_today()}/today-a.md" in answer
+ assert f"daily/{_today()}/today-b.md" in answer
+ await store.close()
-def test_daily_list_filters_by_date():
+@pytest.mark.asyncio
+async def test_daily_list_filters_by_date():
"""Explicit ``date`` scopes to that day's folder."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "a", "a"),
- ("2026-05-17", "b", "b"),
- ],
- )
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
- assert payload["date"] == "2026-05-18"
- assert payload["count"] == 1
- answer = step.context.response.answer
- assert "daily/2026-05-18/a.md" in answer
- await store.close()
- print("✓ test_daily_list_filters_by_date passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "a", "a"),
+ ("2026-05-17", "b", "b"),
+ ],
+ )
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
+ assert payload["date"] == "2026-05-18"
+ assert payload["count"] == 1
+ answer = step.context.response.answer
+ assert "daily/2026-05-18/a.md" in answer
+ await store.close()
-def test_daily_list_returns_path_and_frontmatter_notes():
+@pytest.mark.asyncio
+async def test_daily_list_returns_path_and_frontmatter_notes():
"""Each note row exposes path plus the full frontmatter dict."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- await _seed_note(
- "2026-05-18",
- "Alpha Project",
- name="Alpha Project",
- description="JWT auth migration",
- session_id="session-123",
- source_conversation='"[[sessions/dialog/session-123.jsonl]]"',
- )
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
- assert payload["count"] == 1
- assert payload["notes"] == [
- {
- "path": "daily/2026-05-18/Alpha Project.md",
- "name": "Alpha Project",
- "description": "JWT auth migration",
- "session_id": "session-123",
- "source_conversation": "[[sessions/dialog/session-123.jsonl]]",
- },
- ]
- answer = step.context.response.answer
- assert "daily/2026-05-18/Alpha Project.md" in answer
- assert "Alpha Project" in answer
- assert "JWT auth migration" in answer
- assert "session-123" in answer
- assert "[[sessions/dialog/session-123.jsonl]]" in answer
- await store.close()
- print("✓ test_daily_list_returns_path_and_frontmatter_notes passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ await _seed_note(
+ "2026-05-18",
+ "Alpha Project",
+ name="Alpha Project",
+ description="JWT auth migration",
+ session_id="session-123",
+ source_conversation='"[[sessions/dialog/session-123.jsonl]]"',
+ )
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
+ assert payload["count"] == 1
+ assert payload["notes"] == [
+ {
+ "path": "daily/2026-05-18/Alpha Project.md",
+ "name": "Alpha Project",
+ "description": "JWT auth migration",
+ "session_id": "session-123",
+ "source_conversation": "[[sessions/dialog/session-123.jsonl]]",
+ },
+ ]
+ answer = step.context.response.answer
+ assert "daily/2026-05-18/Alpha Project.md" in answer
+ assert "Alpha Project" in answer
+ assert "JWT auth migration" in answer
+ assert "session-123" in answer
+ assert "[[sessions/dialog/session-123.jsonl]]" in answer
+ await store.close()
-def test_daily_list_ignores_subdirectories():
+@pytest.mark.asyncio
+async def test_daily_list_ignores_subdirectories():
"""Subdirectories under the day folder are skipped — only direct .md files count."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "main", "main body"),
- ],
- )
- stray = Path(tmp) / "daily" / "2026-05-18" / "old-folder"
- stray.mkdir(parents=True, exist_ok=True)
- (stray / "old-folder.md").write_text(
- "---\nname: old\n---\nstale\n",
- encoding="utf-8",
- )
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "main", "main body"),
+ ],
+ )
+ stray = Path(tmp) / "daily" / "2026-05-18" / "old-folder"
+ stray.mkdir(parents=True, exist_ok=True)
+ (stray / "old-folder.md").write_text(
+ "---\nname: old\n---\nstale\n",
+ encoding="utf-8",
+ )
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
- assert payload["count"] == 1
- answer = step.context.response.answer
- assert "daily/2026-05-18/main.md" in answer
- await store.close()
- print("✓ test_daily_list_ignores_subdirectories passed")
-
- asyncio.run(run())
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
+ assert payload["count"] == 1
+ answer = step.context.response.answer
+ assert "daily/2026-05-18/main.md" in answer
+ await store.close()
-def test_daily_list_empty_when_no_daily_dir():
+@pytest.mark.asyncio
+async def test_daily_list_empty_when_no_daily_dir():
"""No daily/ folder ⇒ empty notes list, no crash."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
- assert payload == {"date": "2026-05-18", "count": 0, "notes": []}
- await store.close()
- print("✓ test_daily_list_empty_when_no_daily_dir passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
+ assert payload == {"date": "2026-05-18", "count": 0, "notes": []}
+ await store.close()
-def test_daily_list_does_not_refresh_index():
+@pytest.mark.asyncio
+async def test_daily_list_does_not_refresh_index():
"""daily_list is a pure read — it must NOT touch daily/.md."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "alpha", "a"),
- ("2026-05-18", "beta", "b"),
- ],
- )
- index_path = Path(tmp) / "daily" / "2026-05-18.md"
- assert not index_path.exists()
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "alpha", "a"),
+ ("2026-05-18", "beta", "b"),
+ ],
+ )
+ index_path = Path(tmp) / "daily" / "2026-05-18.md"
+ assert not index_path.exists()
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
- assert not index_path.exists(), "daily_list must not refresh the day index — use daily_reindex"
- await store.close()
- print("✓ test_daily_list_does_not_refresh_index passed")
-
- asyncio.run(run())
+ assert not index_path.exists(), "daily_list must not refresh the day index — use daily_reindex"
+ await store.close()
-def test_daily_list_response_shape():
+@pytest.mark.asyncio
+async def test_daily_list_response_shape():
"""daily_list returns {date, count, notes}."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "alpha", "a"),
- ],
- )
- step = daily_list_step.DailyListStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
- assert set(payload.keys()) == {"date", "count", "notes"}
- assert payload["notes"] == [{"path": "daily/2026-05-18/alpha.md", "name": "alpha"}]
- await store.close()
- print("✓ test_daily_list_response_shape passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "alpha", "a"),
+ ],
+ )
+ step = daily_list_step.DailyListStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
+ assert set(payload.keys()) == {"date", "count", "notes"}
+ assert payload["notes"] == [{"path": "daily/2026-05-18/alpha.md", "name": "alpha"}]
+ await store.close()
# -- daily_write_step ---------------------------------------------------------
-def test_daily_write_delegates_to_write_and_refreshes_index():
+@pytest.mark.asyncio
+async def test_daily_write_delegates_to_write_and_refreshes_index():
"""``daily_write`` writes body/frontmatter through ``write_step`` and refreshes the day index."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies([])
- step = await _make_daily_write_step(store, tmp)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies([])
+ step = await _make_daily_write_step(store, tmp)
- await step(
- name="project-plan",
- description="Plan note",
- session_id="2f85a30f4faa41b39380cabb647c1b5b",
- content="body text",
- metadata={"tag": "kept"},
- )
- payload = _metadata(step)
+ await step(
+ name="project-plan",
+ description="Plan note",
+ session_id="2f85a30f4faa41b39380cabb647c1b5b",
+ content="body text",
+ metadata={"tag": "kept"},
+ )
+ payload = _metadata(step)
- assert step.context.response.success is True
- assert payload["path"] == f"daily/{_today()}/project-plan.md"
- assert payload["source_conversation"] == "[[session/dialog/2f85a30f4faa41b39380cabb647c1b5b.jsonl]]"
+ assert step.context.response.success is True
+ assert payload["path"] == f"daily/{_today()}/project-plan.md"
+ assert payload["source_conversation"] == "[[session/dialog/2f85a30f4faa41b39380cabb647c1b5b.jsonl]]"
- note = Path(tmp) / "daily" / _today() / "project-plan.md"
- post = frontmatter.loads(note.read_text(encoding="utf-8"))
- assert post.metadata["name"] == "project-plan"
- assert post.metadata["description"] == "Plan note"
- assert post.metadata["session_id"] == "2f85a30f4faa41b39380cabb647c1b5b"
- assert post.metadata["source_conversation"] == "[[session/dialog/2f85a30f4faa41b39380cabb647c1b5b.jsonl]]"
- assert post.metadata["tag"] == "kept"
- assert post.content.strip() == "body text"
+ note = Path(tmp) / "daily" / _today() / "project-plan.md"
+ post = frontmatter.loads(note.read_text(encoding="utf-8"))
+ assert post.metadata["name"] == "project-plan"
+ assert post.metadata["description"] == "Plan note"
+ assert post.metadata["session_id"] == "2f85a30f4faa41b39380cabb647c1b5b"
+ assert post.metadata["source_conversation"] == "[[session/dialog/2f85a30f4faa41b39380cabb647c1b5b.jsonl]]"
+ assert post.metadata["tag"] == "kept"
+ assert post.content.strip() == "body text"
- index = Path(tmp) / "daily" / f"{_today()}.md"
- assert index.is_file()
- assert f"[[daily/{_today()}/project-plan.md]]" in index.read_text(encoding="utf-8")
- await store.close()
- print("✓ test_daily_write_delegates_to_write_and_refreshes_index passed")
-
- asyncio.run(run())
+ index = Path(tmp) / "daily" / f"{_today()}.md"
+ assert index.is_file()
+ assert f"[[daily/{_today()}/project-plan.md]]" in index.read_text(encoding="utf-8")
+ await store.close()
-def test_daily_write_accepts_explicit_date():
+@pytest.mark.asyncio
+async def test_daily_write_accepts_explicit_date():
"""``daily_write`` can write historical notes instead of always using today's folder."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies([])
- step = await _make_daily_write_step(store, tmp)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies([])
+ step = await _make_daily_write_step(store, tmp)
- await step(
- name="historical-event",
- description="Historical note",
- session_id="locomo-session",
- content="body text",
- date="2023-01-19",
- )
- payload = _metadata(step)
+ await step(
+ name="historical-event",
+ description="Historical note",
+ session_id="locomo-session",
+ content="body text",
+ date="2023-01-19",
+ )
+ payload = _metadata(step)
- assert step.context.response.success is True
- assert payload["date"] == "2023-01-19"
- assert payload["path"] == "daily/2023-01-19/historical-event.md"
- assert (Path(tmp) / "daily" / "2023-01-19" / "historical-event.md").is_file()
- assert "[[daily/2023-01-19/historical-event.md]]" in (Path(tmp) / "daily" / "2023-01-19.md").read_text(
- encoding="utf-8",
- )
- await store.close()
- print("✓ test_daily_write_accepts_explicit_date passed")
-
- asyncio.run(run())
+ assert step.context.response.success is True
+ assert payload["date"] == "2023-01-19"
+ assert payload["path"] == "daily/2023-01-19/historical-event.md"
+ assert (Path(tmp) / "daily" / "2023-01-19" / "historical-event.md").is_file()
+ assert "[[daily/2023-01-19/historical-event.md]]" in (Path(tmp) / "daily" / "2023-01-19.md").read_text(
+ encoding="utf-8",
+ )
+ await store.close()
-def test_daily_write_rejects_timestamp_as_explicit_date():
+@pytest.mark.asyncio
+async def test_daily_write_rejects_timestamp_as_explicit_date():
"""Explicit ``date`` is strict YYYY-MM-DD, not a timestamp-like value."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies([])
- step = await _make_daily_write_step(store, tmp)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies([])
+ step = await _make_daily_write_step(store, tmp)
- await step(
- name="historical-event",
- description="Historical note",
- session_id="locomo-session",
- content="body text",
- date="2023-01-19T08:00:00",
- )
+ await step(
+ name="historical-event",
+ description="Historical note",
+ session_id="locomo-session",
+ content="body text",
+ date="2023-01-19T08:00:00",
+ )
- assert step.context.response.success is False
- assert step.context.response.answer == "Error: date must be YYYY-MM-DD"
- assert step.context.response.metadata["date"] == "2023-01-19T08:00:00"
- assert not (Path(tmp) / "daily").exists()
- await store.close()
- print("✓ test_daily_write_rejects_timestamp_as_explicit_date passed")
-
- asyncio.run(run())
+ assert step.context.response.success is False
+ assert step.context.response.answer == "Error: date must be YYYY-MM-DD"
+ assert step.context.response.metadata["date"] == "2023-01-19T08:00:00"
+ assert not (Path(tmp) / "daily").exists()
+ await store.close()
-def test_daily_write_reserved_metadata_keys_are_overridden():
+@pytest.mark.asyncio
+async def test_daily_write_reserved_metadata_keys_are_overridden():
"""User metadata cannot override daily_write's fixed frontmatter fields."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies([])
- step = await _make_daily_write_step(store, tmp)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies([])
+ step = await _make_daily_write_step(store, tmp)
- await step(
- name="real-name",
- description="Real description",
- session_id="real-session",
- content="body",
- metadata={
- "name": "bad-name",
- "description": "bad-description",
- "session_id": "bad-session",
- "source_conversation": "[[bad]]",
- "priority": 2,
- },
- )
+ await step(
+ name="real-name",
+ description="Real description",
+ session_id="real-session",
+ content="body",
+ metadata={
+ "name": "bad-name",
+ "description": "bad-description",
+ "session_id": "bad-session",
+ "source_conversation": "[[bad]]",
+ "priority": 2,
+ },
+ )
- post = frontmatter.loads((Path(tmp) / "daily" / _today() / "real-name.md").read_text(encoding="utf-8"))
- assert post.metadata["name"] == "real-name"
- assert post.metadata["description"] == "Real description"
- assert post.metadata["session_id"] == "real-session"
- assert post.metadata["source_conversation"] == "[[session/dialog/real-session.jsonl]]"
- assert post.metadata["priority"] == 2
- await store.close()
- print("✓ test_daily_write_reserved_metadata_keys_are_overridden passed")
-
- asyncio.run(run())
+ post = frontmatter.loads((Path(tmp) / "daily" / _today() / "real-name.md").read_text(encoding="utf-8"))
+ assert post.metadata["name"] == "real-name"
+ assert post.metadata["description"] == "Real description"
+ assert post.metadata["session_id"] == "real-session"
+ assert post.metadata["source_conversation"] == "[[session/dialog/real-session.jsonl]]"
+ assert post.metadata["priority"] == 2
+ await store.close()
-def test_daily_write_rejects_invalid_name_and_session_id():
+@pytest.mark.asyncio
+async def test_daily_write_rejects_invalid_name_and_session_id():
"""Path-bearing fields are validated before write job execution."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies([])
- step = await _make_daily_write_step(store, tmp)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies([])
+ step = await _make_daily_write_step(store, tmp)
- await step(name="bad/name", description="d", session_id="ok", content="body")
- assert step.context.response.success is False
- await step(name="ok", description="d", session_id="bad/session", content="body")
- assert step.context.response.success is False
- assert not (Path(tmp) / "daily" / _today()).exists()
- await store.close()
- print("✓ test_daily_write_rejects_invalid_name_and_session_id passed")
-
- asyncio.run(run())
+ await step(name="bad/name", description="d", session_id="ok", content="body")
+ assert step.context.response.success is False
+ await step(name="ok", description="d", session_id="bad/session", content="body")
+ assert step.context.response.success is False
+ assert not (Path(tmp) / "daily" / _today()).exists()
+ await store.close()
# -- day index: daily/.md ------------------------------------------
@@ -449,249 +398,200 @@ def _day_index_text(tmp: str, day: str) -> str:
return (Path(tmp) / "daily" / f"{day}.md").read_text(encoding="utf-8")
-def test_day_index_lists_each_note():
+@pytest.mark.asyncio
+async def test_day_index_lists_each_note():
"""Multiple notes all show up in the index notes block with name."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- await _seed_note("2026-05-18", "alpha", name="Alpha Project")
- await _seed_note("2026-05-18", "beta", name="Beta Project")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ await _seed_note("2026-05-18", "alpha", name="Alpha Project")
+ await _seed_note("2026-05-18", "beta", name="Beta Project")
- await daily_reindex_step.DailyReindexStep(file_store=store)(date="2026-05-18")
- text = _day_index_text(tmp, "2026-05-18")
- assert "[[daily/2026-05-18/alpha.md]]" in text
- assert "[[daily/2026-05-18/beta.md]]" in text
- assert "Alpha Project" in text
- assert "Beta Project" in text
- await store.close()
- print("✓ test_day_index_lists_each_note passed")
-
- asyncio.run(run())
+ await daily_reindex_step.DailyReindexStep(file_store=store)(date="2026-05-18")
+ text = _day_index_text(tmp, "2026-05-18")
+ assert "[[daily/2026-05-18/alpha.md]]" in text
+ assert "[[daily/2026-05-18/beta.md]]" in text
+ assert "Alpha Project" in text
+ assert "Beta Project" in text
+ await store.close()
-def test_day_index_includes_note_descriptions():
+@pytest.mark.asyncio
+async def test_day_index_includes_note_descriptions():
"""Each note line inlines the full frontmatter (single-line, key: value pairs)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- cases = [
- ("alpha", "Alpha Project", "实现 JWT auth 中间件,迁移 session middleware"),
- ("beta", "beta", "调研增值税新政对 SaaS 的影响"),
- ("gamma", "Gamma", ""),
- ]
- for sid, name, description in cases:
- await _seed_note("2026-05-18", sid, name=name, description=description)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ cases = [
+ ("alpha", "Alpha Project", "实现 JWT auth 中间件,迁移 session middleware"),
+ ("beta", "beta", "调研增值税新政对 SaaS 的影响"),
+ ("gamma", "Gamma", ""),
+ ]
+ for sid, name, description in cases:
+ await _seed_note("2026-05-18", sid, name=name, description=description)
- await daily_reindex_step.DailyReindexStep(file_store=store)(date="2026-05-18")
- text = _day_index_text(tmp, "2026-05-18")
- # name + description inline on the same line as the wikilink
- assert "[[daily/2026-05-18/alpha.md]] name: Alpha Project description: 实现 JWT auth 中间件" in text
- assert "[[daily/2026-05-18/beta.md]] name: beta description: 调研增值税新政对 SaaS 的影响" in text
- # gamma has no description → only name is emitted, no trailing `description:` cruft
- assert "[[daily/2026-05-18/gamma.md]] name: Gamma\n" in text or text.rstrip().endswith(
- "[[daily/2026-05-18/gamma.md]] name: Gamma",
- )
- assert "description:" not in text.split("[[daily/2026-05-18/gamma.md]]")[1].split("\n")[0]
- await store.close()
- print("✓ test_day_index_includes_note_descriptions passed")
-
- asyncio.run(run())
+ await daily_reindex_step.DailyReindexStep(file_store=store)(date="2026-05-18")
+ text = _day_index_text(tmp, "2026-05-18")
+ # name + description inline on the same line as the wikilink
+ assert "[[daily/2026-05-18/alpha.md]] name: Alpha Project description: 实现 JWT auth 中间件" in text
+ assert "[[daily/2026-05-18/beta.md]] name: beta description: 调研增值税新政对 SaaS 的影响" in text
+ # gamma has no description → only name is emitted, no trailing `description:` cruft
+ assert "[[daily/2026-05-18/gamma.md]] name: Gamma\n" in text or text.rstrip().endswith(
+ "[[daily/2026-05-18/gamma.md]] name: Gamma",
+ )
+ assert "description:" not in text.split("[[daily/2026-05-18/gamma.md]]")[1].split("\n")[0]
+ await store.close()
-def test_day_index_hides_conversation_metadata():
+@pytest.mark.asyncio
+async def test_day_index_hides_conversation_metadata():
"""The day index omits conversation metadata while keeping it in the returned notes payload."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- await _seed_note(
- "2026-05-18",
- "alpha",
- name="Alpha Project",
- description="JWT auth migration",
- session_id="session-123",
- source_conversation='"[[session/dialog/session-123.jsonl]]"',
- )
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ await _seed_note(
+ "2026-05-18",
+ "alpha",
+ name="Alpha Project",
+ description="JWT auth migration",
+ session_id="session-123",
+ source_conversation='"[[session/dialog/session-123.jsonl]]"',
+ )
- refreshed = await daily_reindex_step.refresh_day_index(store, "2026-05-18", "daily")
- assert refreshed["notes"][0]["session_id"] == "session-123"
- assert refreshed["notes"][0]["source_conversation"] == "[[session/dialog/session-123.jsonl]]"
+ refreshed = await daily_reindex_step.refresh_day_index(store, "2026-05-18", "daily")
+ assert refreshed["notes"][0]["session_id"] == "session-123"
+ assert refreshed["notes"][0]["source_conversation"] == "[[session/dialog/session-123.jsonl]]"
- text = _day_index_text(tmp, "2026-05-18")
- line = text.split("[[daily/2026-05-18/alpha.md]]", 1)[1].split("\n", 1)[0]
- assert "name: Alpha Project" in line
- assert "description: JWT auth migration" in line
- assert "session_id" not in line
- assert "session-123" not in line
- assert "source_conversation" not in line
- assert "[[session/dialog/session-123.jsonl]]" not in line
- await store.close()
- print("✓ test_day_index_hides_conversation_metadata passed")
-
- asyncio.run(run())
+ text = _day_index_text(tmp, "2026-05-18")
+ line = text.split("[[daily/2026-05-18/alpha.md]]", 1)[1].split("\n", 1)[0]
+ assert "name: Alpha Project" in line
+ assert "description: JWT auth migration" in line
+ assert "session_id" not in line
+ assert "session-123" not in line
+ assert "source_conversation" not in line
+ assert "[[session/dialog/session-123.jsonl]]" not in line
+ await store.close()
-def test_day_index_description_is_note_count():
+@pytest.mark.asyncio
+async def test_day_index_description_is_note_count():
"""The typed ``description`` field carries a one-line note-count digest."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "a", "a body"),
- ("2026-05-18", "b", "b body"),
- ],
- )
- step = daily_reindex_step.DailyReindexStep(file_store=store)
- await step(date="2026-05-18")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "a", "a body"),
+ ("2026-05-18", "b", "b body"),
+ ],
+ )
+ step = daily_reindex_step.DailyReindexStep(file_store=store)
+ await step(date="2026-05-18")
- text = _day_index_text(tmp, "2026-05-18")
- assert "description:" in text
- assert "2 note(s) today." in text
- await store.close()
- print("✓ test_day_index_description_is_note_count passed")
-
- asyncio.run(run())
+ text = _day_index_text(tmp, "2026-05-18")
+ assert "description:" in text
+ assert "2 note(s) today." in text
+ await store.close()
-def test_day_index_description_updates_when_note_count_changes():
+@pytest.mark.asyncio
+async def test_day_index_description_updates_when_note_count_changes():
"""Reindexing an existing day index refreshes the note-count description."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- await _seed_note("2026-05-18", "alpha")
- reindex = daily_reindex_step.DailyReindexStep(file_store=store)
- await reindex(date="2026-05-18")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ await _seed_note("2026-05-18", "alpha")
+ reindex = daily_reindex_step.DailyReindexStep(file_store=store)
+ await reindex(date="2026-05-18")
- await _seed_note("2026-05-18", "beta")
- await reindex(date="2026-05-18")
+ await _seed_note("2026-05-18", "beta")
+ await reindex(date="2026-05-18")
- text = _day_index_text(tmp, "2026-05-18")
- assert "2 note(s) today." in text
- assert "1 note(s) today." not in text
- await store.close()
- print("✓ test_day_index_description_updates_when_note_count_changes passed")
-
- asyncio.run(run())
+ text = _day_index_text(tmp, "2026-05-18")
+ assert "2 note(s) today." in text
+ assert "1 note(s) today." not in text
+ await store.close()
-def test_day_index_preserves_user_content_outside_marker():
+@pytest.mark.asyncio
+async def test_day_index_preserves_user_content_outside_marker():
"""Any user-authored content sitting outside the auto markers is
preserved verbatim across refreshes."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- await _seed_note("2026-05-18", "alpha")
- reindex = daily_reindex_step.DailyReindexStep(file_store=store)
- await reindex(date="2026-05-18")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ await _seed_note("2026-05-18", "alpha")
+ reindex = daily_reindex_step.DailyReindexStep(file_store=store)
+ await reindex(date="2026-05-18")
- index_path = Path(tmp) / "daily" / "2026-05-18.md"
- text = index_path.read_text(encoding="utf-8")
- # Append user content AFTER the auto block; it should survive refresh.
- user_block = "\n\n## 我的笔记\nMY HAND-WRITTEN NOTE\n这是我手写的备忘,不该被覆盖\n"
- index_path.write_text(text.rstrip() + user_block, encoding="utf-8")
+ index_path = Path(tmp) / "daily" / "2026-05-18.md"
+ text = index_path.read_text(encoding="utf-8")
+ # Append user content AFTER the auto block; it should survive refresh.
+ user_block = "\n\n## 我的笔记\nMY HAND-WRITTEN NOTE\n这是我手写的备忘,不该被覆盖\n"
+ index_path.write_text(text.rstrip() + user_block, encoding="utf-8")
- await _seed_note("2026-05-18", "beta")
- await reindex(date="2026-05-18")
- after = index_path.read_text(encoding="utf-8")
- assert "MY HAND-WRITTEN NOTE" in after
- assert "这是我手写的备忘" in after
- assert "## 我的笔记" in after
- assert "[[daily/2026-05-18/beta.md]]" in after
- await store.close()
- print("✓ test_day_index_preserves_user_content_outside_marker passed")
-
- asyncio.run(run())
+ await _seed_note("2026-05-18", "beta")
+ await reindex(date="2026-05-18")
+ after = index_path.read_text(encoding="utf-8")
+ assert "MY HAND-WRITTEN NOTE" in after
+ assert "这是我手写的备忘" in after
+ assert "## 我的笔记" in after
+ assert "[[daily/2026-05-18/beta.md]]" in after
+ await store.close()
# -- daily_reindex_step -----------------------------------------------------
-def test_daily_reindex_returns_write_view():
+@pytest.mark.asyncio
+async def test_daily_reindex_returns_write_view():
"""daily_reindex returns {date, path, created, notes_count}."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [
- ("2026-05-18", "alpha", "a body"),
- ("2026-05-18", "beta", "b body"),
- ],
- )
- assert not (Path(tmp) / "daily" / "2026-05-18.md").exists()
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [
+ ("2026-05-18", "alpha", "a body"),
+ ("2026-05-18", "beta", "b body"),
+ ],
+ )
+ assert not (Path(tmp) / "daily" / "2026-05-18.md").exists()
- step = daily_reindex_step.DailyReindexStep(file_store=store)
- await step(date="2026-05-18")
- payload = _metadata(step)
+ step = daily_reindex_step.DailyReindexStep(file_store=store)
+ await step(date="2026-05-18")
+ payload = _metadata(step)
- assert set(payload.keys()) == {"date", "path", "created", "notes_count"}
- assert payload["date"] == "2026-05-18"
- assert payload["path"] == "daily/2026-05-18.md"
- assert payload["created"] is True
- assert payload["notes_count"] == 2
+ assert set(payload.keys()) == {"date", "path", "created", "notes_count"}
+ assert payload["date"] == "2026-05-18"
+ assert payload["path"] == "daily/2026-05-18.md"
+ assert payload["created"] is True
+ assert payload["notes_count"] == 2
- text = _day_index_text(tmp, "2026-05-18")
- assert "[[daily/2026-05-18/alpha.md]]" in text
- assert "[[daily/2026-05-18/beta.md]]" in text
- await store.close()
- print("✓ test_daily_reindex_returns_write_view passed")
-
- asyncio.run(run())
+ text = _day_index_text(tmp, "2026-05-18")
+ assert "[[daily/2026-05-18/alpha.md]]" in text
+ assert "[[daily/2026-05-18/beta.md]]" in text
+ await store.close()
-def test_daily_reindex_created_flag_flips_on_rerun():
+@pytest.mark.asyncio
+async def test_daily_reindex_created_flag_flips_on_rerun():
"""First call creates the index (created=True); re-run reports created=False."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store_with_dailies(
- [("2026-05-18", "alpha", "a")],
- )
- step = daily_reindex_step.DailyReindexStep(file_store=store)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store_with_dailies(
+ [("2026-05-18", "alpha", "a")],
+ )
+ step = daily_reindex_step.DailyReindexStep(file_store=store)
- await step(date="2026-05-18")
- payload_first = _metadata(step)
- assert payload_first["created"] is True
+ await step(date="2026-05-18")
+ payload_first = _metadata(step)
+ assert payload_first["created"] is True
- await step(date="2026-05-18")
- payload_second = _metadata(step)
- assert payload_second["created"] is False
- assert payload_second["notes_count"] == 1
- await store.close()
- print("✓ test_daily_reindex_created_flag_flips_on_rerun passed")
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== Daily step tests ===")
- test_daily_list_default_date_is_today()
- test_daily_list_filters_by_date()
- test_daily_list_returns_path_and_frontmatter_notes()
- test_daily_list_ignores_subdirectories()
- test_daily_list_empty_when_no_daily_dir()
- test_daily_list_does_not_refresh_index()
- test_daily_list_response_shape()
- test_daily_write_delegates_to_write_and_refreshes_index()
- test_daily_write_accepts_explicit_date()
- test_daily_write_rejects_timestamp_as_explicit_date()
- test_daily_write_reserved_metadata_keys_are_overridden()
- test_daily_write_rejects_invalid_name_and_session_id()
- test_day_index_lists_each_note()
- test_day_index_includes_note_descriptions()
- test_day_index_hides_conversation_metadata()
- test_day_index_description_is_note_count()
- test_day_index_description_updates_when_note_count_changes()
- test_day_index_preserves_user_content_outside_marker()
- test_daily_reindex_returns_write_view()
- test_daily_reindex_created_flag_flips_on_rerun()
- print("\nAll tests passed!")
+ await step(date="2026-05-18")
+ payload_second = _metadata(step)
+ assert payload_second["created"] is False
+ assert payload_second["notes_count"] == 1
+ await store.close()
diff --git a/tests/unit/test_decision_model.py b/tests/unit/test_decision_model.py
index 1ec36779..1a4c3beb 100644
--- a/tests/unit/test_decision_model.py
+++ b/tests/unit/test_decision_model.py
@@ -238,6 +238,88 @@ def test_failed_or_cancelled_call_does_not_block_close(provider, failure):
asyncio.run(run())
+@pytest.mark.parametrize("borrowed", [False, True])
+def test_cancelled_close_drains_requests_and_releases_model(provider, borrowed):
+ """Shutdown defers cancellation until active calls and model cleanup finish."""
+ _, model = provider
+
+ async def run():
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def reply(**_kwargs):
+ started.set()
+ await release.wait()
+
+ model.side_effect = reply
+ component = AgentScopeAsDecisionModel(credential={"api_key": "test-key"})
+ if borrowed:
+ component.model = model
+ call = asyncio.create_task(component(state="hello", questions={"q": {"type": "binary"}}))
+ await asyncio.wait_for(started.wait(), 2)
+ close = asyncio.create_task(component.close())
+ await asyncio.sleep(0)
+ try:
+ for _ in range(2):
+ close.cancel()
+ await asyncio.sleep(0)
+ assert not close.done()
+ assert component.is_started
+ assert component.model is model
+ model.client.aclose.assert_not_awaited()
+ release.set()
+ await asyncio.wait_for(call, 2)
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(close, 2)
+ assert component.model is None
+ assert not component.is_started
+ assert not component._owns_model
+ await component.close()
+ assert model.client.aclose.await_count == (0 if borrowed else 1)
+ finally:
+ release.set()
+ await asyncio.gather(call, close, return_exceptions=True)
+
+ asyncio.run(run())
+
+
+def test_cancelled_close_finishes_owned_client_cleanup(provider):
+ """Repeated shutdown cancellation cannot interrupt the client's own close."""
+ _, model = provider
+
+ async def run():
+ closing, release, finished = asyncio.Event(), asyncio.Event(), asyncio.Event()
+
+ async def close_client():
+ closing.set()
+ await release.wait()
+ finished.set()
+
+ model.client.aclose.side_effect = close_client
+ component = AgentScopeAsDecisionModel(credential={"api_key": "test-key"})
+ await component(state="hello", questions={"q": {"type": "binary"}})
+ close = asyncio.create_task(component.close())
+ await asyncio.wait_for(closing.wait(), 2)
+ try:
+ for _ in range(2):
+ close.cancel()
+ await asyncio.sleep(0)
+ assert not close.done()
+ assert component.is_started
+ release.set()
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(close, 2)
+ assert finished.is_set()
+ assert component.model is None
+ assert not component.is_started
+ await component.close()
+ model.client.aclose.assert_awaited_once()
+ finally:
+ release.set()
+ await asyncio.gather(close, return_exceptions=True)
+
+ asyncio.run(run())
+
+
def test_client_close_error_clears_cached_model(provider):
"""A failed close cannot leave a stale client selected on the next start."""
factory, model = provider
@@ -350,3 +432,80 @@ def test_adapter_uses_sdk_default_or_explicit_model(monkeypatch, model_name):
model.client.aclose.assert_awaited_once()
asyncio.run(run())
+
+
+def test_injected_model_is_borrowed_and_accepts_json_questions(provider, tmp_path):
+ """Application injection shares the host instance without owning its client."""
+ from reme.application import Application
+
+ factory, model = provider
+
+ async def run():
+ app = Application(
+ workspace_dir=str(tmp_path),
+ enable_logo=False,
+ log_to_console=False,
+ log_to_file=False,
+ service={"backend": "http"},
+ components={"as_decision_model": {"compaction": {"backend": "agentscope"}}},
+ )
+ component = await app.update_component("as_decision_model", "compaction", model=model)
+ assert component.model is model
+ async with app:
+ await component(state="hello", questions={"q": {"type": "binary"}})
+ factory.assert_not_called()
+ model.assert_awaited_once()
+ model.client.aclose.assert_not_awaited()
+ assert component.model is None
+
+ asyncio.run(run())
+
+
+def test_model_replacement_guards_owned_clients(provider):
+ """Replacing an owned client requires an explicit close to prevent leaks."""
+ _, model = provider
+
+ async def run():
+ component = AgentScopeAsDecisionModel(credential={"api_key": "test-key"})
+ await component(state="hello", questions={"q": {"type": "binary"}})
+ component.model = model
+ with pytest.raises(RuntimeError, match="Close"):
+ component.model = None
+ await component.close()
+ component.model = model
+ await component.close()
+ # The borrowed instance is never closed a second time.
+ model.client.aclose.assert_awaited_once()
+ with pytest.raises(TypeError, match="callable"):
+ component.model = "model-name"
+
+ asyncio.run(run())
+
+
+def test_borrowed_model_cannot_be_replaced_while_in_use(provider):
+ """Host ownership still obeys the component's in-flight request boundary."""
+ _, model = provider
+
+ async def run():
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def reply(**_kwargs):
+ started.set()
+ await release.wait()
+
+ model.side_effect = reply
+ component = AgentScopeAsDecisionModel()
+ component.model = model
+ task = asyncio.create_task(component(state="hello", questions={"q": {"type": "binary"}}))
+ await asyncio.wait_for(started.wait(), 2)
+ with pytest.raises(RuntimeError, match="Close"):
+ component.model = None
+ close = asyncio.create_task(component.close())
+ await asyncio.sleep(0)
+ assert not close.done()
+ release.set()
+ await task
+ await close
+ model.client.aclose.assert_not_awaited()
+
+ asyncio.run(run())
diff --git a/tests/unit/test_default_file_chunker.py b/tests/unit/test_default_file_chunker.py
index 17665318..b2b8cf23 100644
--- a/tests/unit/test_default_file_chunker.py
+++ b/tests/unit/test_default_file_chunker.py
@@ -18,6 +18,7 @@ from reme.utils.wikilink_handler import WikilinkHandler
"content",
[
"",
+ "你好世界\n测试内容",
"# H\nline 1\nline 2\n",
"---\nname: t\n---\n# H\nline 1\nline 2\n",
"---\nname: t\n---\n\n\n# H\nline 1\nline 2\n",
@@ -37,6 +38,7 @@ def test_file_extensions_preserve_identical_plain_text(tmp_path, content, newlin
node, chunks = asyncio.run(DefaultFileChunker().chunk(path))
assert path.read_bytes() == source
+ assert node.path == str(path)
assert node.front_matter == FileFrontMatter()
assert node.links == []
assert node.chunk_ids == [chunk.id for chunk in chunks]
@@ -95,93 +97,6 @@ def test_wikilinks_do_not_change_plain_text_boundaries(tmp_path, suffix):
assert node.front_matter == FileFrontMatter()
-def test_parse_empty_file():
- """Test parsing an empty file."""
-
- async def run():
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
- temp_path = f.name
-
- try:
- chunker = DefaultFileChunker()
- file_node, chunks = await chunker.chunk(temp_path)
- assert file_node.path == temp_path
- assert len(chunks) == 0
- print("✓ test_parse_empty_file passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
-def test_parse_small_file():
- """Test parsing a file smaller than chunk size."""
-
- async def run():
- content = "Hello World\nThis is a test\nLine 3"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
- f.write(content)
- temp_path = f.name
-
- try:
- chunker = DefaultFileChunker(chunk_byte_size=10000)
- _, chunks = await chunker.chunk(temp_path)
- assert len(chunks) == 1
- assert chunks[0].start_line == 1
- assert chunks[0].end_line == 3
- assert chunks[0].text == content
- print("✓ test_parse_small_file passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
-def test_parse_chunked_file():
- """Test parsing a file that requires multiple chunks."""
-
- async def run():
- # Create content larger than chunk size
- lines = ["A" * 100 for _ in range(200)] # ~20200 bytes
- content = "\n".join(lines)
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
- f.write(content)
- temp_path = f.name
-
- try:
- chunker = DefaultFileChunker(chunk_byte_size=5000, overlap_byte_size=100)
- _, chunks = await chunker.chunk(temp_path)
- assert len(chunks) > 1, f"Expected multiple chunks, got {len(chunks)}"
- # Verify overlap by checking that consecutive chunks share some content
- print(f" Created {len(chunks)} chunks")
- print("✓ test_parse_chunked_file passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
-def test_parse_with_custom_encoding():
- """Test parsing a file with different encodings."""
-
- async def run():
- content = "你好世界\n测试内容"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt", encoding="utf-8") as f:
- f.write(content)
- temp_path = f.name
-
- try:
- chunker = DefaultFileChunker(encoding="utf-8")
- _, chunks = await chunker.chunk(temp_path)
- assert len(chunks) >= 1
- assert "你好世界" in chunks[0].text
- print("✓ test_parse_with_custom_encoding passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
def test_invalid_encoding_policy_is_validated():
"""Reject misspelled policies instead of silently changing decoding behavior."""
try:
@@ -201,48 +116,44 @@ def test_constructor_preserves_positional_arguments():
assert chunker.overlap_byte_size == 100
-def test_newlines_are_normalized_before_chunking():
+@pytest.mark.asyncio
+async def test_newlines_are_normalized_before_chunking():
"""Binary reads retain the universal-newline behavior of the old text reader."""
- async def run():
- with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
- f.write(b"alpha\r\nbeta\rgamma\n")
- temp_path = f.name
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
+ f.write(b"alpha\r\nbeta\rgamma\n")
+ temp_path = f.name
- try:
- chunker = DefaultFileChunker()
- _, original_chunks = await chunker.chunk(temp_path)
- with open(temp_path, "wb") as f:
- f.write(b"alpha\nbeta\ngamma\n")
- _, normalized_chunks = await chunker.chunk(temp_path)
+ try:
+ chunker = DefaultFileChunker()
+ _, original_chunks = await chunker.chunk(temp_path)
+ with open(temp_path, "wb") as f:
+ f.write(b"alpha\nbeta\ngamma\n")
+ _, normalized_chunks = await chunker.chunk(temp_path)
- assert [chunk.text for chunk in original_chunks] == ["alpha\nbeta\ngamma\n"]
- assert [chunk.id for chunk in original_chunks] == [chunk.id for chunk in normalized_chunks]
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
+ assert [chunk.text for chunk in original_chunks] == ["alpha\nbeta\ngamma\n"]
+ assert [chunk.id for chunk in original_chunks] == [chunk.id for chunk in normalized_chunks]
+ finally:
+ os.unlink(temp_path)
-def test_ascii_replacement_remains_encodable():
+@pytest.mark.asyncio
+async def test_ascii_replacement_remains_encodable():
"""Replacement mode must survive byte-based chunking with a single-byte codec."""
- async def run():
- source = b"valid\xffinvalid"
- with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
- f.write(source)
- temp_path = f.name
+ source = b"valid\xffinvalid"
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f:
+ f.write(source)
+ temp_path = f.name
- try:
- _, chunks = await DefaultFileChunker(encoding="ascii").chunk(temp_path)
+ try:
+ _, chunks = await DefaultFileChunker(encoding="ascii").chunk(temp_path)
- assert [chunk.text for chunk in chunks] == ["valid?invalid"]
- with open(temp_path, "rb") as f:
- assert f.read() == source
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
+ assert [chunk.text for chunk in chunks] == ["valid?invalid"]
+ with open(temp_path, "rb") as f:
+ assert f.read() == source
+ finally:
+ os.unlink(temp_path)
def test_parse_links_bare():
@@ -253,7 +164,6 @@ def test_parse_links_bare():
assert link.source_path == "src.md"
assert link.target_path == "note"
assert link.target_anchor is None
- print("✓ test_parse_links_bare passed")
def test_parse_links_with_anchor():
@@ -262,7 +172,6 @@ def test_parse_links_with_anchor():
assert len(links) == 1
assert links[0].target_path == "note"
assert links[0].target_anchor == "section A"
- print("✓ test_parse_links_with_anchor passed")
def test_parse_links_alias_dropped():
@@ -271,7 +180,6 @@ def test_parse_links_alias_dropped():
assert len(links) == 1
assert links[0].target_path == "note"
assert links[0].target_anchor is None
- print("✓ test_parse_links_alias_dropped passed")
def test_parse_links_anchor_and_alias():
@@ -280,7 +188,6 @@ def test_parse_links_anchor_and_alias():
assert len(links) == 1
assert links[0].target_path == "note"
assert links[0].target_anchor == "sec"
- print("✓ test_parse_links_anchor_and_alias passed")
def test_parse_links_ignores_legacy_relation_wrappers():
@@ -304,7 +211,6 @@ def test_parse_links_multiple_on_one_line():
("x", None),
("y", "h"),
]
- print("✓ test_parse_links_multiple_on_one_line passed")
def test_parse_wikilink_line_ranges():
@@ -336,105 +242,75 @@ def test_parse_links_no_match():
assert len(WikilinkHandler.extract_links("no link here :: foo", "src.md")) == 0
assert len(WikilinkHandler.extract_links("plain text without brackets", "src.md")) == 0
assert len(WikilinkHandler.extract_links("", "src.md")) == 0
- print("✓ test_parse_links_no_match passed")
-def test_parse_links_in_file():
+@pytest.mark.asyncio
+async def test_parse_links_in_file():
"""Literal wikilinks and YAML remain text without populating node metadata."""
- async def run():
- content = (
- "---\n"
- "name: demo\n"
- "---\n"
- "\n"
- "Intro paragraph with [[alpha]] and [[beta#h2]].\n"
- "author:: [[Alice]]\n"
- "[ref:: [[paper#chapter 1]]]\n"
- )
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- f.write(content)
- temp_path = f.name
+ content = (
+ "---\n"
+ "name: demo\n"
+ "---\n"
+ "\n"
+ "Intro paragraph with [[alpha]] and [[beta#h2]].\n"
+ "author:: [[Alice]]\n"
+ "[ref:: [[paper#chapter 1]]]\n"
+ )
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write(content)
+ temp_path = f.name
- try:
- chunker = DefaultFileChunker()
- file_node, chunks = await chunker.chunk(temp_path)
- assert file_node.links == []
- assert file_node.front_matter == FileFrontMatter()
- assert len(chunks) == 1
- assert chunks[0].text == content
- print("✓ test_parse_links_in_file passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
+ try:
+ chunker = DefaultFileChunker()
+ file_node, chunks = await chunker.chunk(temp_path)
+ assert file_node.links == []
+ assert file_node.front_matter == FileFrontMatter()
+ assert len(chunks) == 1
+ assert chunks[0].text == content
+ finally:
+ os.unlink(temp_path)
-def test_parse_links_empty_when_no_content():
+@pytest.mark.asyncio
+async def test_parse_links_empty_when_no_content():
"""Empty file and front-matter-only file both yield no links."""
- async def run():
- # Empty file
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- empty_path = f.name
- # Front-matter-only file
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- f.write("---\nname: x\n---\n")
- fm_only_path = f.name
+ # Empty file
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ empty_path = f.name
+ # Front-matter-only file
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write("---\nname: x\n---\n")
+ fm_only_path = f.name
- try:
- chunker = DefaultFileChunker()
- node1, _ = await chunker.chunk(empty_path)
- node2, _ = await chunker.chunk(fm_only_path)
- assert node1.links == []
- assert node2.links == []
- print("✓ test_parse_links_empty_when_no_content passed")
- finally:
- os.unlink(empty_path)
- os.unlink(fm_only_path)
-
- asyncio.run(run())
+ try:
+ chunker = DefaultFileChunker()
+ node1, _ = await chunker.chunk(empty_path)
+ node2, _ = await chunker.chunk(fm_only_path)
+ assert node1.links == []
+ assert node2.links == []
+ finally:
+ os.unlink(empty_path)
+ os.unlink(fm_only_path)
-def test_min_chunk_and_overlap_size():
+@pytest.mark.asyncio
+async def test_min_chunk_and_overlap_size():
"""Test that minimum chunk and overlap sizes are enforced."""
- async def run():
- # These values should be clamped to minimums
- chunker = DefaultFileChunker(chunk_byte_size=1, overlap_byte_size=0)
- assert chunker.chunk_byte_size == 100 # minimum
- assert chunker.overlap_byte_size == 4 # minimum
+ # These values should be clamped to minimums
+ chunker = DefaultFileChunker(chunk_byte_size=1, overlap_byte_size=0)
+ assert chunker.chunk_byte_size == 100 # minimum
+ assert chunker.overlap_byte_size == 4 # minimum
- content = "test"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
- f.write(content)
- temp_path = f.name
+ content = "test"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
- try:
- _, chunks = await chunker.chunk(temp_path)
- assert len(chunks) == 1
- print("✓ test_min_chunk_and_overlap_size passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- test_parse_empty_file()
- test_parse_small_file()
- test_parse_chunked_file()
- test_parse_with_custom_encoding()
- test_parse_links_bare()
- test_parse_links_with_anchor()
- test_parse_links_alias_dropped()
- test_parse_links_anchor_and_alias()
- test_parse_links_ignores_legacy_relation_wrappers()
- test_parse_links_multiple_on_one_line()
- test_parse_wikilink_line_ranges()
- test_parse_markdown_links_are_ignored()
- test_parse_links_no_match()
- test_parse_links_in_file()
- test_parse_links_empty_when_no_content()
- test_min_chunk_and_overlap_size()
- print("\n所有测试通过!")
+ try:
+ _, chunks = await chunker.chunk(temp_path)
+ assert len(chunks) == 1
+ finally:
+ os.unlink(temp_path)
diff --git a/tests/unit/test_dream_checkpoint_versions.py b/tests/unit/test_dream_checkpoint_versions.py
index 38afe875..ec97e112 100644
--- a/tests/unit/test_dream_checkpoint_versions.py
+++ b/tests/unit/test_dream_checkpoint_versions.py
@@ -16,7 +16,7 @@ from reme.components.application_context import ApplicationContext
from reme.components.file_catalog import LocalFileCatalog
from reme.components.runtime_context import RuntimeContext
from reme.config import resolve_app_config
-from reme.schema import DreamState
+from reme.schema import DreamState, FileNode
from reme.steps.evolve.dream.finish import DreamFinishStep
DAY = "2026-05-28"
@@ -141,6 +141,99 @@ async def test_dream_reprocesses_concurrent_source_edits_after_restart(tmp_path,
await app.close()
+@pytest.mark.asyncio
+@pytest.mark.parametrize("older_checkpoint", [False, True])
+@pytest.mark.parametrize("recover_on_attempt", [None, 1, 2])
+async def test_invalid_integration_preserves_retry_after_restart(tmp_path, older_checkpoint, recover_on_attempt):
+ """Unrecoverable integration preserves catalog versions; actual writes can still recover."""
+ source = tmp_path / SOURCE
+ source.parent.mkdir(parents=True)
+ original = frontmatter.dumps(frontmatter.Post(OLD, name="deployment", description="Deployment policy"))
+ source.write_text(original)
+ index = f"daily/{DAY}.md"
+ (tmp_path / index).write_text("day index")
+
+ def agent_for(app, write_attempt):
+ calls = {"extract": 0, "integrate": 0}
+
+ async def reply(_message, **kwargs):
+ if "list_tags" in kwargs["job_tools"]:
+ return {"result": "No tags added by the controlled provider"}
+ if kwargs["job_tools"] == ["read"]:
+ calls["extract"] += 1
+ unit = {"name": "deployment", "bucket": "wiki", "summary": OLD, "paths": [SOURCE, index]}
+ return {"result": json.dumps({"units": [unit]})}
+ calls["integrate"] += 1
+ if calls["integrate"] == write_attempt:
+ written = await app.run_job("write", path=TARGET, content=OLD)
+ assert written.success, written.answer
+ return {"result": "{}"}
+
+ agent = AsyncMock(spec=BaseAgentWrapper)
+ agent.reply.side_effect = reply
+ return agent, calls
+
+ app = _app(tmp_path)
+ await app.start()
+ previous = {rel: (tmp_path / rel).stat().st_mtime - 2 for rel in (SOURCE, index)} if older_checkpoint else {}
+ try:
+ catalog = app.context.components["file_catalog"]["dream"]
+ await catalog.upsert([FileNode(path=rel, st_mtime=mtime) for rel, mtime in previous.items()])
+ agent, calls = agent_for(app, recover_on_attempt)
+ response = await app.run_job(
+ "auto_dream",
+ date=DAY,
+ scan_days=1,
+ agent_wrapper=agent,
+ as_llm=Mock(spec=ChatModelBase),
+ )
+ state = response.metadata["dream"]
+ assert response.success is (recover_on_attempt is not None)
+ assert state["skipped_units"] == []
+ assert calls == {"extract": 1, "integrate": recover_on_attempt or 2}
+ assert source.read_text() == original
+ if recover_on_attempt is None:
+ assert response.answer.startswith("AutoDream failed")
+ assert len(state["failed_units"]) == 1
+ assert set(state["failed_paths"]) == {SOURCE, index}
+ assert state["checkpoint_paths"] == []
+ assert state["errors"]
+ assert {n.path: n.st_mtime for n in await catalog.get_nodes()} == previous
+ assert not (tmp_path / TARGET).exists()
+ else:
+ assert response.answer.startswith("AutoDream completed with warnings")
+ assert state["failed_units"] == []
+ assert state["failed_paths"] == []
+ assert not state["errors"]
+ assert set(state["checkpoint_paths"]) == {SOURCE, index}
+ assert state["integrate_results"][0]["target_path"] == TARGET
+ assert OLD in (tmp_path / TARGET).read_text()
+ finally:
+ await app.close()
+
+ app = _app(tmp_path)
+ await app.start()
+ try:
+ catalog = app.context.components["file_catalog"]["dream"]
+ if recover_on_attempt is None:
+ assert {n.path: n.st_mtime for n in await catalog.get_nodes()} == previous
+ agent, calls = agent_for(app, 1)
+ response = await app.run_job(
+ "auto_dream",
+ date=DAY,
+ scan_days=1,
+ agent_wrapper=agent,
+ as_llm=Mock(spec=ChatModelBase),
+ )
+ assert response.success, response.answer
+ assert calls == {"extract": int(recover_on_attempt is None), "integrate": int(recover_on_attempt is None)}
+ assert {SOURCE, index} <= {n.path for n in await catalog.get_nodes()}
+ assert OLD in (tmp_path / TARGET).read_text()
+ assert source.read_text() == original
+ finally:
+ await app.close()
+
+
@pytest.mark.asyncio
async def test_finish_keeps_scanned_versions_for_changed_sources_and_day_indexes(tmp_path):
"""Supplemental indexes obey the same snapshot and failed-source rules as notes."""
diff --git a/tests/unit/test_faiss_index_maintenance.py b/tests/unit/test_faiss_index_maintenance.py
index ca2a490c..d98f3af8 100644
--- a/tests/unit/test_faiss_index_maintenance.py
+++ b/tests/unit/test_faiss_index_maintenance.py
@@ -2,356 +2,248 @@
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
-import time
-import numpy as np
import pytest
-from reme.components.file_store import FaissLocalFileStore, LocalFileStore
-from reme.schema import FileChunk, FileNode
+
+from reme.components.file_store import LocalFileStore
from reme.steps.index import OptimizeIndexStep
-
-class temp_chdir:
- """Temporarily chdir into a test workspace."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
+from .file_store_test_support import (
+ FakeEmbeddingStore,
+ _new_faiss_store,
+ _seed_unembedded_chunk,
+ _settle_reindex,
+ chunk,
+ node,
+)
-class FakeEmbeddingStore:
- """Small deterministic embedding provider used by file-store tests."""
-
- dimensions = 2
- max_batch_size = 10
- is_healthy = True
-
- def _embed(self, text: str) -> np.ndarray:
- if "beta" in text or "fresh" in text:
- return np.array([0.0, 1.0], dtype=np.float16)
- return np.array([1.0, 0.0], dtype=np.float16)
-
- async def health_check(self, _timeout: float = 2.0) -> bool:
- """Report the fake embedding service as healthy."""
- return True
-
- async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray:
- """Return a deterministic embedding for a single text."""
- return self._embed(input_text)
-
- async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
- """Attach deterministic embeddings to file chunks."""
- for chunk_node in nodes:
- chunk_node.embedding = self._embed(chunk_node.text)
- return nodes
-
-
-def run(coro):
- """Run an async test body."""
- return asyncio.run(coro)
-
-
-def node(path: str) -> FileNode:
- """Build a minimal file node."""
- return FileNode(path=path, st_mtime=1.0)
-
-
-def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk:
- """Build a minimal file chunk."""
- return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata)
-
-
-def _new_faiss_store(name, **kwargs):
- """Construct a FAISS store with embedding disabled at bind time."""
- try:
- store = FaissLocalFileStore(name=name, embedding_store="", **kwargs)
- except ImportError:
- pytest.skip("faiss is not installed")
- return store
-
-
-async def _settle_reindex(store, timeout=5.0):
- """Wait until no async reindex is pending or in flight."""
- deadline = time.monotonic() + timeout
- while store._reindex_event.is_set() or store._reindex_busy:
- if time.monotonic() > deadline:
- raise AssertionError("async reindex did not settle in time")
- await asyncio.sleep(0.005)
-
-
-async def _seed_unembedded_chunk(store: LocalFileStore, chunk_id: str, path: str, text: str) -> None:
- """Attach one chunk without a vector, keeping the graph invariant intact."""
- store.file_chunks[chunk_id] = chunk(chunk_id, path, text)
- file_node = node(path)
- file_node.chunk_ids = [chunk_id]
- await store.file_graph.upsert_nodes([file_node])
-
-
-def test_faiss_backfill_adds_incrementally_without_rebuild():
+@pytest.mark.asyncio
+async def test_faiss_backfill_adds_incrementally_without_rebuild():
"""Backfilled vectors are added to the live index; no full rebuild happens
while tombstones stay under the compaction threshold."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_backfill_incr")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_backfill_incr")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
+ # Same-id text change tombstones the old row (below the 128 floor).
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
+ assert store._tombstones == {0}
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
- # Same-id text change tombstones the old row (below the 128 floor).
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
- assert store._tombstones == {0}
+ await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
+ await store._backfill_missing_embeddings()
- await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
- await store._backfill_missing_embeddings()
-
- # The new vector is live; the surviving tombstone proves the index
- # was extended in place rather than rebuilt.
- assert set(store._id_to_row) == {"a", "b", "c"}
- assert store._tombstones == {0}
- assert store._reindex_worker_task is None
- assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "b", "c"}
- await store.close()
-
- run(go())
+ # The new vector is live; the surviving tombstone proves the index
+ # was extended in place rather than rebuilt.
+ assert set(store._id_to_row) == {"a", "b", "c"}
+ assert store._tombstones == {0}
+ assert store._reindex_worker_task is None
+ assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "b", "c"}
+ await store.close()
-def test_faiss_backfill_compacts_when_tombstones_cross_threshold():
+@pytest.mark.asyncio
+async def test_faiss_backfill_compacts_when_tombstones_cross_threshold():
"""Tombstone pressure at backfill time still triggers a full rebuild."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_backfill_compact")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_backfill_compact")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
+ assert store._tombstones == {0}
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
- assert store._tombstones == {0}
+ # Lower the threshold only now, so the upsert above did not compact.
+ store.max_tombstones = 1
+ await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
+ await store._backfill_missing_embeddings()
- # Lower the threshold only now, so the upsert above did not compact.
- store.max_tombstones = 1
- await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
- await store._backfill_missing_embeddings()
-
- assert store._tombstones == set()
- assert set(store._id_to_row) == {"a", "c"}
- assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "c"}
- await store.close()
-
- run(go())
+ assert store._tombstones == set()
+ assert set(store._id_to_row) == {"a", "c"}
+ assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "c"}
+ await store.close()
-def test_faiss_backfill_mass_delta_uses_async_reindex():
+@pytest.mark.asyncio
+async def test_faiss_backfill_mass_delta_uses_async_reindex():
"""With async_reindex, a delta that rivals the live rows (initial mass
backfill into an empty index) goes through the background worker."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_backfill_async_mass", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_backfill_async_mass", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await _seed_unembedded_chunk(store, "a", "a.md", "alpha text")
+ await _seed_unembedded_chunk(store, "b", "b.md", "beta text")
+ await store._backfill_missing_embeddings()
- await _seed_unembedded_chunk(store, "a", "a.md", "alpha text")
- await _seed_unembedded_chunk(store, "b", "b.md", "beta text")
- await store._backfill_missing_embeddings()
-
- assert store._reindex_worker_task is not None # routed off-loop
- await _settle_reindex(store)
- assert set(store._id_to_row) == {"a", "b"}
- assert [c.id for c in await store.vector_search("alpha", 5, {})][0] == "a"
- await store.close()
-
- run(go())
+ assert store._reindex_worker_task is not None # routed off-loop
+ await _settle_reindex(store)
+ assert set(store._id_to_row) == {"a", "b"}
+ assert [c.id for c in await store.vector_search("alpha", 5, {})][0] == "a"
+ await store.close()
-def test_faiss_backfill_small_delta_adds_inline_in_async_mode():
+@pytest.mark.asyncio
+async def test_faiss_backfill_small_delta_adds_inline_in_async_mode():
"""With async_reindex, a small delta is added inline without a worker."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_backfill_async_small", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_backfill_async_small", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
+ await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
+ await store._backfill_missing_embeddings()
- await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
- await store._backfill_missing_embeddings()
-
- # 1 new vector < 2 live rows -> inline add, no background worker.
- assert store._reindex_worker_task is None
- assert set(store._id_to_row) == {"a", "b", "c"}
- assert {c.id for c in await store.vector_search("alpha", 10, {})} >= {"a", "c"}
- await store.close()
-
- run(go())
+ # 1 new vector < 2 live rows -> inline add, no background worker.
+ assert store._reindex_worker_task is None
+ assert set(store._id_to_row) == {"a", "b", "c"}
+ assert {c.id for c in await store.vector_search("alpha", 10, {})} >= {"a", "c"}
+ await store.close()
# -- optimize_index (idle-time maintenance) -------------------------------------
-def test_tombstone_threshold_scales_and_honors_override():
+@pytest.mark.asyncio
+async def test_tombstone_threshold_scales_and_honors_override():
"""Threshold math: full vs half scale, 128 floor, and max_tombstones override."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_threshold")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = type("FakeIndex", (), {"ntotal": 1000})()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_threshold")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = type("FakeIndex", (), {"ntotal": 1000})()
+ assert store._tombstone_threshold() == 300 # 0.3 * 1000
+ assert store._tombstone_threshold(scale=0.5) == 150 # half the write-path bar
+ store._faiss_index = type("FakeIndex", (), {"ntotal": 100})()
+ assert store._tombstone_threshold(scale=0.5) == 128 # floor dominates
- assert store._tombstone_threshold() == 300 # 0.3 * 1000
- assert store._tombstone_threshold(scale=0.5) == 150 # half the write-path bar
- store._faiss_index = type("FakeIndex", (), {"ntotal": 100})()
- assert store._tombstone_threshold(scale=0.5) == 128 # floor dominates
+ store.max_tombstones = 7
+ assert store._tombstone_threshold() == 7
+ assert store._tombstone_threshold(scale=0.5) == 7 # fixed override ignores scale
- store.max_tombstones = 7
- assert store._tombstone_threshold() == 7
- assert store._tombstone_threshold(scale=0.5) == 7 # fixed override ignores scale
-
- store._faiss_index = None # avoid persisting the fake index on close
- await store.close()
-
- run(go())
+ store._faiss_index = None # avoid persisting the fake index on close
+ await store.close()
-def test_local_store_optimize_index_is_noop():
+@pytest.mark.asyncio
+async def test_local_store_optimize_index_is_noop():
"""LocalFileStore.optimize_index() completes without touching store state."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_local_optimize", embedding_store="")
+ await store.start()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_local_optimize", embedding_store="")
- await store.start()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.optimize_index()
- await store.optimize_index()
-
- assert set(store.file_chunks) == {"a"}
- assert [c.id for c in await store.keyword_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert set(store.file_chunks) == {"a"}
+ assert [c.id for c in await store.keyword_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_faiss_optimize_index_noop_below_threshold():
+@pytest.mark.asyncio
+async def test_faiss_optimize_index_noop_below_threshold():
"""optimize_index() keeps tombstones when they are under the half bar."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_optimize_noop")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_optimize_noop")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
+ assert store._tombstones == {0}
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha updated")])])
- assert store._tombstones == {0}
+ await store.optimize_index() # 1 tombstone <= 128 floor -> untouched
- await store.optimize_index() # 1 tombstone <= 128 floor -> untouched
-
- assert store._tombstones == {0}
- assert store._reindex_worker_task is None
- await store.close()
-
- run(go())
+ assert store._tombstones == {0}
+ assert store._reindex_worker_task is None
+ await store.close()
-def test_faiss_optimize_index_compacts_inline_when_sync():
+@pytest.mark.asyncio
+async def test_faiss_optimize_index_compacts_inline_when_sync():
"""optimize_index() rebuilds inline once tombstones exceed the (overridden) bar."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_optimize_sync")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_optimize_sync")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
+ await store.upsert(files)
+ updated = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", f"alpha v2 {i}")]) for i in range(3)]
+ await store.upsert(updated)
+ assert len(store._tombstones) == 3
- files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
- await store.upsert(files)
- updated = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", f"alpha v2 {i}")]) for i in range(3)]
- await store.upsert(updated)
- assert len(store._tombstones) == 3
+ store.max_tombstones = 2 # lower the bar only for optimize_index
+ await store.optimize_index()
- store.max_tombstones = 2 # lower the bar only for optimize_index
- await store.optimize_index()
-
- assert store._tombstones == set()
- assert set(store._id_to_row) == {"c0", "c1", "c2", "c3"}
- assert store._reindex_worker_task is None # inline path
- assert len(await store.vector_search("alpha", 10, {})) == 4
- await store.close()
-
- run(go())
+ assert store._tombstones == set()
+ assert set(store._id_to_row) == {"c0", "c1", "c2", "c3"}
+ assert store._reindex_worker_task is None # inline path
+ assert len(await store.vector_search("alpha", 10, {})) == 4
+ await store.close()
-def test_faiss_optimize_index_uses_worker_when_async():
+@pytest.mark.asyncio
+async def test_faiss_optimize_index_uses_worker_when_async():
"""optimize_index() submits the rebuild to the background worker under async_reindex."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_optimize_async", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_optimize_async", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
+ await store.upsert(files)
+ updated = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", f"alpha v2 {i}")]) for i in range(3)]
+ await store.upsert(updated)
+ assert len(store._tombstones) == 3
- files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
- await store.upsert(files)
- updated = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", f"alpha v2 {i}")]) for i in range(3)]
- await store.upsert(updated)
- assert len(store._tombstones) == 3
+ store.max_tombstones = 2
+ await store.optimize_index()
- store.max_tombstones = 2
- await store.optimize_index()
-
- assert store._reindex_worker_task is not None # routed off-loop
- await _settle_reindex(store)
- assert store._tombstones == set()
- assert set(store._id_to_row) == {"c0", "c1", "c2", "c3"}
- await store.close()
-
- run(go())
+ assert store._reindex_worker_task is not None # routed off-loop
+ await _settle_reindex(store)
+ assert store._tombstones == set()
+ assert set(store._id_to_row) == {"c0", "c1", "c2", "c3"}
+ await store.close()
-def test_optimize_index_step_calls_file_store_optimize_index():
+@pytest.mark.asyncio
+async def test_optimize_index_step_calls_file_store_optimize_index():
"""The cron-facing step delegates to file_store.optimize_index() and reports success."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_optimize_step", embedding_store="")
+ await store.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_optimize_step", embedding_store="")
- await store.start()
+ calls = []
+ original_optimize = store.optimize_index
- calls = []
- original_optimize = store.optimize_index
+ async def counting_optimize():
+ calls.append(True)
+ await original_optimize()
- async def counting_optimize():
- calls.append(True)
- await original_optimize()
+ store.optimize_index = counting_optimize
+ step = OptimizeIndexStep(file_store=store)
+ await step()
- store.optimize_index = counting_optimize
- step = OptimizeIndexStep(file_store=store)
- await step()
-
- assert calls == [True]
- assert step.context.response.metadata["optimized_index"] is True
- await store.close()
-
- run(go())
+ assert calls == [True]
+ assert step.context.response.metadata["optimized_index"] is True
+ await store.close()
diff --git a/tests/unit/test_file_catalog.py b/tests/unit/test_file_catalog.py
index 06c1c90a..cac820e0 100644
--- a/tests/unit/test_file_catalog.py
+++ b/tests/unit/test_file_catalog.py
@@ -2,8 +2,7 @@
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
import pytest
@@ -13,22 +12,6 @@ from reme.schema import FileNode
from reme.utils.jsonl_zst import write_jsonl_zst
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def make_node(path: str, mtime: float = 1.0) -> FileNode:
"""Build a FileNode with the given path and mtime for fixture use."""
return FileNode(path=path, st_mtime=mtime)
@@ -39,147 +22,119 @@ BACKENDS = [LocalFileCatalog]
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_upsert_and_get_nodes(backend_cls):
+@pytest.mark.asyncio
+async def test_upsert_and_get_nodes(backend_cls):
"""upsert stores nodes; get_nodes returns by path or all."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- catalog = backend_cls()
- await catalog.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ catalog = backend_cls()
+ await catalog.start()
- await catalog.upsert([make_node("a.md"), make_node("b.md")])
+ await catalog.upsert([make_node("a.md"), make_node("b.md")])
- got_all = await catalog.get_nodes()
- assert {n.path for n in got_all} == {"a.md", "b.md"}
+ got_all = await catalog.get_nodes()
+ assert {n.path for n in got_all} == {"a.md", "b.md"}
- got_one = await catalog.get_nodes(["a.md"])
- assert len(got_one) == 1
- assert got_one[0].path == "a.md"
+ got_one = await catalog.get_nodes(["a.md"])
+ assert len(got_one) == 1
+ assert got_one[0].path == "a.md"
- got_missing = await catalog.get_nodes(["nope.md"])
- assert got_missing == []
+ got_missing = await catalog.get_nodes(["nope.md"])
+ assert got_missing == []
- await catalog.close()
- print(f"✓ test_upsert_and_get_nodes[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await catalog.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_upsert_replaces_existing(backend_cls):
+@pytest.mark.asyncio
+async def test_upsert_replaces_existing(backend_cls):
"""Re-upserting a node with the same path overwrites the prior entry."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- catalog = backend_cls()
- await catalog.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ catalog = backend_cls()
+ await catalog.start()
- await catalog.upsert([make_node("a.md", mtime=1.0)])
- await catalog.upsert([make_node("a.md", mtime=2.0)])
+ await catalog.upsert([make_node("a.md", mtime=1.0)])
+ await catalog.upsert([make_node("a.md", mtime=2.0)])
- nodes = await catalog.get_nodes(["a.md"])
- assert len(nodes) == 1
- assert nodes[0].st_mtime == 2.0
+ nodes = await catalog.get_nodes(["a.md"])
+ assert len(nodes) == 1
+ assert nodes[0].st_mtime == 2.0
- await catalog.close()
- print(f"✓ test_upsert_replaces_existing[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await catalog.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_delete_single_and_list(backend_cls):
+@pytest.mark.asyncio
+async def test_delete_single_and_list(backend_cls):
"""delete accepts both a single path and a list; missing paths are no-ops."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- catalog = backend_cls()
- await catalog.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ catalog = backend_cls()
+ await catalog.start()
- await catalog.upsert([make_node("a.md"), make_node("b.md"), make_node("c.md")])
+ await catalog.upsert([make_node("a.md"), make_node("b.md"), make_node("c.md")])
- await catalog.delete("a.md")
- assert {n.path for n in await catalog.get_nodes()} == {"b.md", "c.md"}
+ await catalog.delete("a.md")
+ assert {n.path for n in await catalog.get_nodes()} == {"b.md", "c.md"}
- await catalog.delete(["b.md", "ghost.md"])
- assert {n.path for n in await catalog.get_nodes()} == {"c.md"}
+ await catalog.delete(["b.md", "ghost.md"])
+ assert {n.path for n in await catalog.get_nodes()} == {"c.md"}
- await catalog.close()
- print(f"✓ test_delete_single_and_list[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await catalog.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_get_nodes_empty_inputs(backend_cls):
+@pytest.mark.asyncio
+async def test_get_nodes_empty_inputs(backend_cls):
"""get_nodes([]) returns []; get_nodes(None) returns all."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- catalog = backend_cls()
- await catalog.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ catalog = backend_cls()
+ await catalog.start()
- await catalog.upsert([make_node("a.md")])
- assert await catalog.get_nodes([]) == []
- assert len(await catalog.get_nodes(None)) == 1
+ await catalog.upsert([make_node("a.md")])
+ assert await catalog.get_nodes([]) == []
+ assert len(await catalog.get_nodes(None)) == 1
- await catalog.close()
- print(f"✓ test_get_nodes_empty_inputs[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await catalog.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_persistence_roundtrip(backend_cls):
+@pytest.mark.asyncio
+async def test_persistence_roundtrip(backend_cls):
"""close() dumps; a fresh instance loads the same nodes from disk."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- c1 = backend_cls()
- await c1.start()
- await c1.upsert([make_node("a.md", mtime=10.0), make_node("b.md", mtime=20.0)])
- await c1.close()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ c1 = backend_cls()
+ await c1.start()
+ await c1.upsert([make_node("a.md", mtime=10.0), make_node("b.md", mtime=20.0)])
+ await c1.close()
- c2 = backend_cls()
- await c2.start()
- nodes = sorted(await c2.get_nodes(), key=lambda n: n.path)
- assert [n.path for n in nodes] == ["a.md", "b.md"]
- assert [n.st_mtime for n in nodes] == [10.0, 20.0]
- await c2.close()
- print(f"✓ test_persistence_roundtrip[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ c2 = backend_cls()
+ await c2.start()
+ nodes = sorted(await c2.get_nodes(), key=lambda n: n.path)
+ assert [n.path for n in nodes] == ["a.md", "b.md"]
+ assert [n.st_mtime for n in nodes] == [10.0, 20.0]
+ await c2.close()
-def test_start_failure_does_not_overwrite_persisted_catalog(tmp_path):
+@pytest.mark.asyncio
+async def test_start_failure_does_not_overwrite_persisted_catalog(tmp_path):
"""A partial load must not dump incomplete in-memory state during rollback."""
- async def run():
- with temp_chdir(tmp_path):
- catalog = LocalFileCatalog()
- original_lines = [
- make_node("a.md").model_dump_json(),
- "not valid json",
- make_node("b.md").model_dump_json(),
- ]
- write_jsonl_zst(catalog._catalog_file, original_lines)
- original_bytes = catalog._catalog_file.read_bytes()
+ with temp_chdir(tmp_path):
+ catalog = LocalFileCatalog()
+ original_lines = [
+ make_node("a.md").model_dump_json(),
+ "not valid json",
+ make_node("b.md").model_dump_json(),
+ ]
+ write_jsonl_zst(catalog._catalog_file, original_lines)
+ original_bytes = catalog._catalog_file.read_bytes()
- with pytest.raises(ValueError):
- await catalog.start()
+ with pytest.raises(ValueError):
+ await catalog.start()
- assert catalog.is_started is False
- assert catalog._catalog_file.read_bytes() == original_bytes
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== FileCatalog Tests ===")
- for backend in BACKENDS:
- test_upsert_and_get_nodes(backend)
- test_upsert_replaces_existing(backend)
- test_delete_single_and_list(backend)
- test_get_nodes_empty_inputs(backend)
- test_persistence_roundtrip(backend)
- print("\n所有测试通过!")
+ assert catalog.is_started is False
+ assert catalog._catalog_file.read_bytes() == original_bytes
diff --git a/tests/unit/test_file_graph.py b/tests/unit/test_file_graph.py
index 9c53c92f..46a052e4 100644
--- a/tests/unit/test_file_graph.py
+++ b/tests/unit/test_file_graph.py
@@ -2,8 +2,7 @@
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
import pytest
@@ -13,22 +12,6 @@ from reme.enumeration import LinkScopeEnum
from reme.schema import FileLink, FileNode
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def make_node(path: str, links: list[tuple[str, str | None]] | None = None) -> FileNode:
"""Build a FileNode with the given outgoing (target_path, target_anchor) pairs."""
return FileNode(
@@ -65,67 +48,62 @@ BACKENDS = [LocalFileGraph, NxFileGraph]
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_upsert_and_get_nodes(backend_cls):
+@pytest.mark.asyncio
+async def test_upsert_and_get_nodes(backend_cls):
"""upsert_nodes stores nodes; get_nodes returns them by path or all."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- n1 = make_node("a.md", [("b.md", None)])
- n2 = make_node("b.md")
- await graph.upsert_nodes([n1, n2])
+ n1 = make_node("a.md", [("b.md", None)])
+ n2 = make_node("b.md")
+ await graph.upsert_nodes([n1, n2])
- got_all = await graph.get_nodes()
- assert {n.path for n in got_all} == {"a.md", "b.md"}
+ got_all = await graph.get_nodes()
+ assert {n.path for n in got_all} == {"a.md", "b.md"}
- got_one = await graph.get_nodes(["a.md"])
- assert len(got_one) == 1
- assert got_one[0].path == "a.md"
+ got_one = await graph.get_nodes(["a.md"])
+ assert len(got_one) == 1
+ assert got_one[0].path == "a.md"
- got_missing = await graph.get_nodes(["nope.md"])
- assert got_missing == []
+ got_missing = await graph.get_nodes(["nope.md"])
+ assert got_missing == []
- await graph.close()
- print(f"✓ test_upsert_and_get_nodes[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_upsert_replaces_old_links(backend_cls):
+@pytest.mark.asyncio
+async def test_upsert_replaces_old_links(backend_cls):
"""Re-upserting a node with new links replaces the old outgoing edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None)]),
- make_node("b.md"),
- make_node("c.md"),
- ],
- )
- assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"b.md"}
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
+ assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"b.md"}
- # Replace a's link target from b → c
- await graph.upsert_nodes([make_node("a.md", [("c.md", None)])])
- assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"c.md"}
- # b should no longer have a as an inlink
- assert await graph.get_inlinks("b.md") == []
- assert {lnk.source_path for lnk in await graph.get_inlinks("c.md")} == {"a.md"}
+ # Replace a's link target from b → c
+ await graph.upsert_nodes([make_node("a.md", [("c.md", None)])])
+ assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"c.md"}
+ # b should no longer have a as an inlink
+ assert await graph.get_inlinks("b.md") == []
+ assert {lnk.source_path for lnk in await graph.get_inlinks("c.md")} == {"a.md"}
- await graph.close()
- print(f"✓ test_upsert_replaces_old_links[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_outlinks_include_virtual_targets(backend_cls):
+@pytest.mark.asyncio
+async def test_outlinks_include_virtual_targets(backend_cls):
"""get_outlinks(scope=ALL) surfaces edges into virtual targets too;
scope=VIRTUAL isolates them; default scope=REAL hides them.
@@ -134,37 +112,34 @@ def test_outlinks_include_virtual_targets(backend_cls):
need to see it via the opt-in scopes.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- # a links to b (real) and ghost (virtual)
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None), ("ghost.md", None)]),
- make_node("b.md"),
- ],
- )
+ # a links to b (real) and ghost (virtual)
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("ghost.md", None)]),
+ make_node("b.md"),
+ ],
+ )
- all_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md", scope=LinkScopeEnum.ALL)}
- assert all_targets == {"b.md", "ghost.md"}
+ all_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md", scope=LinkScopeEnum.ALL)}
+ assert all_targets == {"b.md", "ghost.md"}
- virtual_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md", scope=LinkScopeEnum.VIRTUAL)}
- assert virtual_targets == {"ghost.md"}
+ virtual_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md", scope=LinkScopeEnum.VIRTUAL)}
+ assert virtual_targets == {"ghost.md"}
- # Default scope=REAL hides the dangling edge.
- real_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md")}
- assert real_targets == {"b.md"}
+ # Default scope=REAL hides the dangling edge.
+ real_targets = {lnk.target_path for lnk in await graph.get_outlinks("a.md")}
+ assert real_targets == {"b.md"}
- await graph.close()
- print(f"✓ test_outlinks_include_virtual_targets[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_inlinks_visible_for_virtual_target(backend_cls):
+@pytest.mark.asyncio
+async def test_inlinks_visible_for_virtual_target(backend_cls):
"""Edges to a virtual target are queryable via get_inlinks(scope=VIRTUAL/ALL).
The data model stores ``target → {sources}`` regardless of whether the
@@ -173,36 +148,33 @@ def test_inlinks_visible_for_virtual_target(backend_cls):
can fix dangling references.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- # b doesn't exist yet — edge lives against a virtual placeholder.
- await graph.upsert_nodes([make_node("a.md", [("b.md", None)])])
- # Default (real only): b is virtual, so nothing.
- assert await graph.get_inlinks("b.md") == []
- # Opt-in virtual scope: surface the dangling inlink.
- inlinks = await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL)
- assert {lnk.source_path for lnk in inlinks} == {"a.md"}
- # ALL: equivalent here since b is purely virtual.
- inlinks_all = await graph.get_inlinks("b.md", scope=LinkScopeEnum.ALL)
- assert {lnk.source_path for lnk in inlinks_all} == {"a.md"}
+ # b doesn't exist yet — edge lives against a virtual placeholder.
+ await graph.upsert_nodes([make_node("a.md", [("b.md", None)])])
+ # Default (real only): b is virtual, so nothing.
+ assert await graph.get_inlinks("b.md") == []
+ # Opt-in virtual scope: surface the dangling inlink.
+ inlinks = await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL)
+ assert {lnk.source_path for lnk in inlinks} == {"a.md"}
+ # ALL: equivalent here since b is purely virtual.
+ inlinks_all = await graph.get_inlinks("b.md", scope=LinkScopeEnum.ALL)
+ assert {lnk.source_path for lnk in inlinks_all} == {"a.md"}
- # Promoting b to real makes the inlink visible by default again,
- # and the virtual scope now returns nothing.
- await graph.upsert_nodes([make_node("b.md")])
- assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
- assert await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL) == []
+ # Promoting b to real makes the inlink visible by default again,
+ # and the virtual scope now returns nothing.
+ await graph.upsert_nodes([make_node("b.md")])
+ assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
+ assert await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL) == []
- await graph.close()
- print(f"✓ test_inlinks_visible_for_virtual_target[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_delete_keeps_inbound_view(backend_cls):
+@pytest.mark.asyncio
+async def test_delete_keeps_inbound_view(backend_cls):
"""Deleting a node demotes it to virtual; with ``scope=VIRTUAL`` (or
``ALL``) the inbound view is preserved (sources still hold the link),
and outlinks into the now-virtual target are surfaced — the link
@@ -210,245 +182,206 @@ def test_delete_keeps_inbound_view(backend_cls):
Default scope (REAL) hides both views once the target is virtual.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None)]),
- make_node("b.md"),
- ],
- )
- assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
- await graph.delete_nodes(["b.md"])
- # b is no longer a real node
- assert await graph.get_nodes(["b.md"]) == []
- # Default scope=REAL: virtual b has no visible inlinks.
- assert await graph.get_inlinks("b.md") == []
- # scope=VIRTUAL surfaces the preserved dangling reference.
- virtual_inlinks = await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL)
- assert {lnk.source_path for lnk in virtual_inlinks} == {"a.md"}
- # Default also hides a's outlink into virtual b; scope=ALL surfaces it.
- assert await graph.get_outlinks("a.md") == []
- all_outlinks = await graph.get_outlinks("a.md", scope=LinkScopeEnum.ALL)
- assert {lnk.target_path for lnk in all_outlinks} == {"b.md"}
+ await graph.delete_nodes(["b.md"])
+ # b is no longer a real node
+ assert await graph.get_nodes(["b.md"]) == []
+ # Default scope=REAL: virtual b has no visible inlinks.
+ assert await graph.get_inlinks("b.md") == []
+ # scope=VIRTUAL surfaces the preserved dangling reference.
+ virtual_inlinks = await graph.get_inlinks("b.md", scope=LinkScopeEnum.VIRTUAL)
+ assert {lnk.source_path for lnk in virtual_inlinks} == {"a.md"}
+ # Default also hides a's outlink into virtual b; scope=ALL surfaces it.
+ assert await graph.get_outlinks("a.md") == []
+ all_outlinks = await graph.get_outlinks("a.md", scope=LinkScopeEnum.ALL)
+ assert {lnk.target_path for lnk in all_outlinks} == {"b.md"}
- # Re-upsert b — inlinks visible by default again.
- await graph.upsert_nodes([make_node("b.md")])
- assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
+ # Re-upsert b — inlinks visible by default again.
+ await graph.upsert_nodes([make_node("b.md")])
+ assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
- await graph.close()
- print(f"✓ test_delete_keeps_inbound_view[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_delete_outgoing_links_cleared(backend_cls):
+@pytest.mark.asyncio
+async def test_delete_outgoing_links_cleared(backend_cls):
"""Deleting a source node drops its outgoing edges (no inlink left on its targets)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None)]),
- make_node("b.md"),
- ],
- )
- await graph.delete_nodes(["a.md"])
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await graph.delete_nodes(["a.md"])
- assert await graph.get_inlinks("b.md") == []
+ assert await graph.get_inlinks("b.md") == []
- await graph.close()
- print(f"✓ test_delete_outgoing_links_cleared[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_clear(backend_cls):
+@pytest.mark.asyncio
+async def test_clear(backend_cls):
"""clear() removes all nodes and edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None)]),
- make_node("b.md"),
- ],
- )
- await graph.clear()
- assert await graph.get_nodes() == []
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await graph.clear()
+ assert await graph.get_nodes() == []
- await graph.close()
- print(f"✓ test_clear[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_rebuild_links_idempotent(backend_cls):
+@pytest.mark.asyncio
+async def test_rebuild_links_idempotent(backend_cls):
"""rebuild_links produces the same outlink/inlink view as the original upserts."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("b.md", None), ("c.md", "h")]),
- make_node("b.md"),
- make_node("c.md"),
- ],
- )
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("c.md", "h")]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
- before_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
- before_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
+ before_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
+ before_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
- await graph.rebuild_links()
+ await graph.rebuild_links()
- after_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
- after_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
+ after_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
+ after_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
- assert before_out == after_out
- assert before_in == after_in
+ assert before_out == after_out
+ assert before_in == after_in
- await graph.close()
- print(f"✓ test_rebuild_links_idempotent[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ await graph.close()
-def test_nx_rebuild_links_repair_survives_restart():
+@pytest.mark.asyncio
+async def test_nx_rebuild_links_repair_survives_restart():
"""A repaired NetworkX snapshot must not revert to stale serialized edges."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = NxFileGraph()
- await graph.start()
- await graph.upsert_nodes(
- [
- make_node("a.md", [("c.md", None)]),
- make_node("b.md"),
- make_node("c.md"),
- ],
- )
- await graph.dump()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = NxFileGraph()
+ await graph.start()
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("c.md", None)]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
+ await graph.dump()
- # Model an old/torn snapshot: the node payload says a -> b while
- # the separately serialized NetworkX edge still says a -> c.
- graph._graph.nodes["a.md"]["node"] = make_node("a.md", [("b.md", None)])
- await graph.dump()
- await graph.close()
+ # Model an old/torn snapshot: the node payload says a -> b while
+ # the separately serialized NetworkX edge still says a -> c.
+ graph._graph.nodes["a.md"]["node"] = make_node("a.md", [("b.md", None)])
+ await graph.dump()
+ await graph.close()
- repaired = NxFileGraph()
- await repaired.start()
- assert {link.target_path for link in await repaired.get_outlinks("a.md")} == {"c.md"}
- await repaired.rebuild_links()
- assert {link.target_path for link in await repaired.get_outlinks("a.md")} == {"b.md"}
- await repaired.close()
+ repaired = NxFileGraph()
+ await repaired.start()
+ assert {link.target_path for link in await repaired.get_outlinks("a.md")} == {"c.md"}
+ await repaired.rebuild_links()
+ assert {link.target_path for link in await repaired.get_outlinks("a.md")} == {"b.md"}
+ await repaired.close()
- reopened = NxFileGraph()
- await reopened.start()
- assert {link.target_path for link in await reopened.get_outlinks("a.md")} == {"b.md"}
- await reopened.close()
-
- asyncio.run(run())
+ reopened = NxFileGraph()
+ await reopened.start()
+ assert {link.target_path for link in await reopened.get_outlinks("a.md")} == {"b.md"}
+ await reopened.close()
-def test_local_load_merged_state_survives_restart():
+@pytest.mark.asyncio
+async def test_local_load_merged_state_survives_restart():
"""Loading a snapshot into a modified local graph must not suppress its later dump."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- seed = LocalFileGraph()
- await seed.start()
- await seed.upsert_nodes([make_node("a.md")])
- await seed.close()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ seed = LocalFileGraph()
+ await seed.start()
+ await seed.upsert_nodes([make_node("a.md")])
+ await seed.close()
- merged = LocalFileGraph()
- await merged.start()
- await merged.upsert_nodes([make_node("b.md")])
- await merged.load()
- assert {node.path for node in await merged.get_nodes()} == {"a.md", "b.md"}
- await merged.close()
+ merged = LocalFileGraph()
+ await merged.start()
+ await merged.upsert_nodes([make_node("b.md")])
+ await merged.load()
+ assert {node.path for node in await merged.get_nodes()} == {"a.md", "b.md"}
+ await merged.close()
- reopened = LocalFileGraph()
- await reopened.start()
- assert {node.path for node in await reopened.get_nodes()} == {"a.md", "b.md"}
- await reopened.close()
-
- asyncio.run(run())
+ reopened = LocalFileGraph()
+ await reopened.start()
+ assert {node.path for node in await reopened.get_nodes()} == {"a.md", "b.md"}
+ await reopened.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_persistence_roundtrip(backend_cls):
+@pytest.mark.asyncio
+async def test_persistence_roundtrip(backend_cls):
"""close() dumps; a fresh instance loads the same nodes from disk."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- g1 = backend_cls()
- await g1.start()
- await g1.upsert_nodes(
- [
- make_node("a.md", [("b.md", None)]),
- make_node("b.md"),
- ],
- )
- await g1.close() # triggers dump
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ g1 = backend_cls()
+ await g1.start()
+ await g1.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await g1.close() # triggers dump
- g2 = backend_cls()
- await g2.start() # triggers load
- paths = sorted(n.path for n in await g2.get_nodes())
- assert paths == ["a.md", "b.md"]
- # Inlink relationship should also be reconstructable.
- assert {lnk.source_path for lnk in await g2.get_inlinks("b.md")} == {"a.md"}
- await g2.close()
- print(f"✓ test_persistence_roundtrip[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
+ g2 = backend_cls()
+ await g2.start() # triggers load
+ paths = sorted(n.path for n in await g2.get_nodes())
+ assert paths == ["a.md", "b.md"]
+ # Inlink relationship should also be reconstructable.
+ assert {lnk.source_path for lnk in await g2.get_inlinks("b.md")} == {"a.md"}
+ await g2.close()
@pytest.mark.parametrize("backend_cls", BACKENDS)
-def test_get_nodes_empty_inputs(backend_cls):
+@pytest.mark.asyncio
+async def test_get_nodes_empty_inputs(backend_cls):
"""get_nodes([]) returns []; get_nodes(None) returns all."""
- async def run():
- with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
- graph = backend_cls()
- await graph.start()
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
- await graph.upsert_nodes([make_node("a.md")])
- assert await graph.get_nodes([]) == []
- assert len(await graph.get_nodes(None)) == 1
+ await graph.upsert_nodes([make_node("a.md")])
+ assert await graph.get_nodes([]) == []
+ assert len(await graph.get_nodes(None)) == 1
- await graph.close()
- print(f"✓ test_get_nodes_empty_inputs[{backend_cls.__name__}] passed")
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== FileGraph Tests ===")
- for backend in BACKENDS:
- test_upsert_and_get_nodes(backend)
- test_upsert_replaces_old_links(backend)
- test_outlinks_include_virtual_targets(backend)
- test_inlinks_visible_for_virtual_target(backend)
- test_delete_keeps_inbound_view(backend)
- test_delete_outgoing_links_cleared(backend)
- test_clear(backend)
- test_rebuild_links_idempotent(backend)
- test_persistence_roundtrip(backend)
- test_get_nodes_empty_inputs(backend)
- print("\n所有测试通过!")
+ await graph.close()
diff --git a/tests/unit/test_file_store_consistency.py b/tests/unit/test_file_store_consistency.py
index b7750017..346a507e 100644
--- a/tests/unit/test_file_store_consistency.py
+++ b/tests/unit/test_file_store_consistency.py
@@ -2,11 +2,11 @@
# pylint: disable=protected-access,too-many-lines
+from contextlib import chdir as temp_chdir
import asyncio
import base64
import datetime
import json
-import os
import tempfile
import threading
import time
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock
import numpy as np
import pytest
+
from reme.components.file_store import (
FaissLocalFileStore,
LocalFileStore,
@@ -27,51 +28,7 @@ from reme.components.embedding_store import LocalEmbeddingStore
from reme.schema import FileChunk, FileNode
from reme.utils.jsonl_zst import read_jsonl_zst, write_jsonl_zst
-
-class temp_chdir:
- """Temporarily chdir into a test workspace."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
-class FakeEmbeddingStore:
- """Small deterministic embedding provider used by file-store tests."""
-
- dimensions = 2
- max_batch_size = 10
- is_healthy = True
-
- def _embed(self, text: str) -> np.ndarray:
- if "beta" in text or "fresh" in text:
- return np.array([0.0, 1.0], dtype=np.float16)
- return np.array([1.0, 0.0], dtype=np.float16)
-
- async def health_check(self, _timeout: float = 2.0) -> bool:
- """Report the fake embedding service as healthy."""
- return True
-
- async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray:
- """Return a deterministic embedding for a single text."""
- return self._embed(input_text)
-
- async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
- """Attach deterministic embeddings to file chunks."""
- for chunk_node in nodes:
- chunk_node.embedding = self._embed(chunk_node.text)
- return nodes
-
- async def dump(self) -> None:
- """Persist no state for the in-memory fake."""
+from .file_store_test_support import FakeEmbeddingStore, _new_faiss_store, _new_zvec_store, _settle_reindex, chunk, node
class CountingFakeEmbeddingStore(FakeEmbeddingStore):
@@ -118,6 +75,16 @@ class RecoveringEmbeddingStore(CountingFakeEmbeddingStore):
return await super().get_node_embeddings(nodes, **kwargs)
+class PartialEmbeddingStore(FakeEmbeddingStore):
+ """Fake provider that leaves chunks whose text contains "beta" without a vector."""
+
+ async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
+ for chunk_node in nodes:
+ if "beta" not in chunk_node.text:
+ chunk_node.embedding = self._embed(chunk_node.text)
+ return nodes
+
+
class HealthCountingEmbeddingStore(FakeEmbeddingStore):
"""Fake provider that records eager health checks."""
@@ -227,39 +194,11 @@ def run(coro):
return asyncio.run(coro)
-def node(path: str) -> FileNode:
- """Build a minimal file node."""
- return FileNode(path=path, st_mtime=1.0)
-
-
-def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk:
- """Build a minimal file chunk."""
- return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata)
-
-
def _new_local_store(name, **kwargs):
"""Construct a LocalFileStore with embedding disabled at bind time."""
return LocalFileStore(name=name, embedding_store="", **kwargs)
-def _new_faiss_store(name, **kwargs):
- """Construct a FAISS store when the optional backend is installed."""
- try:
- store = FaissLocalFileStore(name=name, embedding_store="", **kwargs)
- except ImportError:
- pytest.skip("faiss is not installed")
- return store
-
-
-def _new_zvec_store(name, **kwargs):
- """Construct a zvec store with embedding disabled at bind time."""
- try:
- store = ZvecLocalFileStore(name=name, embedding_store="", **kwargs)
- except ImportError:
- pytest.skip("zvec is not installed")
- return store
-
-
def _ensure_zvec_collection(store):
"""Materialize the zvec collection once an embedding backend is attached.
@@ -286,25 +225,22 @@ async def set_chunks_with_graph(store: LocalFileStore, chunks: dict[str, FileChu
await store.file_graph.upsert_nodes(nodes)
-def test_keyword_only_upsert_removes_old_chunks_and_docs():
+@pytest.mark.asyncio
+async def test_keyword_only_upsert_removes_old_chunks_and_docs():
"""Keyword-only upsert removes stale chunks and keyword documents."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_keyword_only", embedding_store="")
+ await store.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_keyword_only", embedding_store="")
- await store.start()
+ await store.upsert([(node("note.md"), [chunk("old", "note.md", "obsoleteword only")])])
+ assert [c.id for c in await store.keyword_search("obsoleteword", 5, {})] == ["old"]
- await store.upsert([(node("note.md"), [chunk("old", "note.md", "obsoleteword only")])])
- assert [c.id for c in await store.keyword_search("obsoleteword", 5, {})] == ["old"]
+ await store.upsert([(node("note.md"), [chunk("new", "note.md", "freshword only")])])
- await store.upsert([(node("note.md"), [chunk("new", "note.md", "freshword only")])])
-
- assert "old" not in store.file_chunks
- assert await store.keyword_search("obsoleteword", 5, {}) == []
- assert [c.id for c in await store.keyword_search("freshword", 5, {})] == ["new"]
- await store.close()
-
- run(go())
+ assert "old" not in store.file_chunks
+ assert await store.keyword_search("obsoleteword", 5, {}) == []
+ assert [c.id for c in await store.keyword_search("freshword", 5, {})] == ["new"]
+ await store.close()
def test_close_persists_each_component_once(monkeypatch):
@@ -369,1047 +305,968 @@ def test_close_preserves_subclass_dump_override():
run(go())
-def test_start_does_not_health_check_embedding_without_backfill():
+@pytest.mark.asyncio
+async def test_start_does_not_health_check_embedding_without_backfill():
"""Hot startup keeps local vector retrieval independent of provider health."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_lazy_embedding_health", embedding_store="")
+ embedding_store = HealthCountingEmbeddingStore()
+ store.embedding_store = embedding_store
+ await store.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_lazy_embedding_health", embedding_store="")
- embedding_store = HealthCountingEmbeddingStore()
- store.embedding_store = embedding_store
- await store.start()
-
- assert embedding_store.health_calls == 0
- assert store.embedding_store is embedding_store
- await store.close()
-
- run(go())
+ assert embedding_store.health_calls == 0
+ assert store.embedding_store is embedding_store
+ await store.close()
-def test_load_rebuilds_keyword_index_from_persisted_chunks_when_missing():
+@pytest.mark.asyncio
+async def test_load_rebuilds_keyword_index_from_persisted_chunks_when_missing():
"""Loading persisted chunks repairs a missing keyword index."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_keyword_repair", embedding_store="")
+ await store.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_keyword_repair", embedding_store="")
- await store.start()
+ await store.upsert(
+ [
+ (node("a.md"), [chunk("a", "a.md", "uniquerepairword stock")]),
+ (node("b.md"), [chunk("b", "b.md", "work preference")]),
+ ],
+ )
+ await store.dump()
- await store.upsert(
- [
- (node("a.md"), [chunk("a", "a.md", "uniquerepairword stock")]),
- (node("b.md"), [chunk("b", "b.md", "work preference")]),
- ],
- )
- await store.dump()
+ await store.keyword_index.clear()
+ assert not store.keyword_index.index_file.exists()
+ assert await store.keyword_search("uniquerepairword", 5, {}) == []
- await store.keyword_index.clear()
- assert not store.keyword_index.index_file.exists()
- assert await store.keyword_search("uniquerepairword", 5, {}) == []
+ store.file_chunks.clear()
+ await store.load()
- store.file_chunks.clear()
- await store.load()
-
- assert store.keyword_index.index_file.exists()
- assert [c.id for c in await store.keyword_search("uniquerepairword", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert store.keyword_index.index_file.exists()
+ assert [c.id for c in await store.keyword_search("uniquerepairword", 5, {})] == ["a"]
+ await store.close()
-def test_load_clears_graph_when_persisted_chunks_are_missing():
+@pytest.mark.asyncio
+async def test_load_clears_graph_when_persisted_chunks_are_missing():
"""A surviving graph must not hide a missing chunk store from automatic file ingestion."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = LocalFileStore(name="t_missing_chunks", embedding_store="")
+ await seed.start()
+ indexed_node = node("memory.md")
+ await seed.upsert(
+ [(indexed_node, [chunk("memory-chunk", "memory.md", "remember this")])],
+ )
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = LocalFileStore(name="t_missing_chunks", embedding_store="")
- await seed.start()
- indexed_node = node("memory.md")
- await seed.upsert(
- [(indexed_node, [chunk("memory-chunk", "memory.md", "remember this")])],
- )
- await seed.close()
+ seed.chunks_path.unlink()
+ store = LocalFileStore(name="t_missing_chunks", embedding_store="")
+ await store.start()
- seed.chunks_path.unlink()
- store = LocalFileStore(name="t_missing_chunks", embedding_store="")
- await store.start()
-
- assert store.file_chunks == {}
- assert await store.get_nodes() == []
- assert set(store.keyword_index.document_ids) == set()
- await store.close()
-
- run(go())
+ assert store.file_chunks == {}
+ assert await store.get_nodes() == []
+ assert set(store.keyword_index.document_ids) == set()
+ await store.close()
-def test_load_clears_graph_and_chunks_when_chunk_sets_partially_diverge():
+@pytest.mark.asyncio
+async def test_load_clears_graph_and_chunks_when_chunk_sets_partially_diverge():
"""Missing and orphaned chunks invalidate the atomic derived snapshot."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_torn_chunks", embedding_store="")
- await store.start()
- indexed_node = node("memory.md")
- indexed_node.chunk_ids = ["kept", "missing"]
- await store.file_graph.upsert_nodes([indexed_node])
- store.file_chunks = {
- "kept": chunk("kept", "memory.md", "kept text"),
- "orphaned": chunk("orphaned", "old.md", "orphaned text"),
- }
-
- repaired = await store._repair_graph_chunk_consistency()
-
- assert repaired is True
- assert store.file_chunks == {}
- assert await store.get_nodes() == []
- await store.close()
-
- run(go())
-
-
-def test_load_clears_stale_keyword_index_when_chunks_are_empty():
- """An empty chunk store is still an exact state BM25 must mirror."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="")
- await seed.start()
- await seed.keyword_index.add_docs({"stale": "stale keyword document"})
- await seed.close()
-
- store = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="")
- await store.start()
-
- assert store.file_chunks == {}
- assert set(store.keyword_index.document_ids) == set()
- assert not store.keyword_index.index_file.exists()
- await store.close()
-
- run(go())
-
-
-def test_keyword_sync_rebuilds_when_backend_only_exposes_matching_count():
- """Matching counts cannot prove that a backend contains the expected IDs."""
-
- async def go():
- store = LocalFileStore(name="t_count_only_keyword", embedding_store="")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_torn_chunks", embedding_store="")
+ await store.start()
+ indexed_node = node("memory.md")
+ indexed_node.chunk_ids = ["kept", "missing"]
+ await store.file_graph.upsert_nodes([indexed_node])
store.file_chunks = {
- "expected": chunk("expected", "expected.md", "expected content"),
+ "kept": chunk("kept", "memory.md", "kept text"),
+ "orphaned": chunk("orphaned", "old.md", "orphaned text"),
}
- keyword_index = CountOnlyKeywordIndex(n_docs=1)
- store.keyword_index = keyword_index
+
+ repaired = await store._repair_graph_chunk_consistency()
+
+ assert repaired is True
+ assert store.file_chunks == {}
+ assert await store.get_nodes() == []
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_load_clears_stale_keyword_index_when_chunks_are_empty():
+ """An empty chunk store is still an exact state BM25 must mirror."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="")
+ await seed.start()
+ await seed.keyword_index.add_docs({"stale": "stale keyword document"})
+ await seed.close()
+
+ store = LocalFileStore(name="t_empty_chunk_keyword", embedding_store="")
+ await store.start()
+
+ assert store.file_chunks == {}
+ assert set(store.keyword_index.document_ids) == set()
+ assert not store.keyword_index.index_file.exists()
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_keyword_sync_rebuilds_when_backend_only_exposes_matching_count():
+ """Matching counts cannot prove that a backend contains the expected IDs."""
+ store = LocalFileStore(name="t_count_only_keyword", embedding_store="")
+ store.file_chunks = {
+ "expected": chunk("expected", "expected.md", "expected content"),
+ }
+ keyword_index = CountOnlyKeywordIndex(n_docs=1)
+ store.keyword_index = keyword_index
+
+ await store._sync_keyword_index_from_chunks()
+
+ assert keyword_index.reset_docs == {"expected": "expected content"}
+
+
+@pytest.mark.asyncio
+async def test_keyword_sync_ignores_nonempty_chunk_with_no_indexable_tokens(monkeypatch):
+ """A tokenless chunk omitted by BM25 must not trigger a perpetual rebuild."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_tokenless_keyword", embedding_store="")
+ await store.start()
+ store.file_chunks = {
+ "indexed": chunk("indexed", "data.jsonl", "searchable content"),
+ "tokenless": chunk("tokenless", "data.jsonl", "\u2028"),
+ }
+ await store.keyword_index.clear()
+ await store.keyword_index.add_docs({cid: item.text for cid, item in store.file_chunks.items()})
+ assert set(store.keyword_index.document_ids) == {"indexed"}
+
+ async def unexpected_rebuild(_docs):
+ raise AssertionError("tokenless BM25 content must not trigger a rebuild")
+
+ monkeypatch.setattr(store, "_rebuild_keyword_index", unexpected_rebuild)
+ await store._sync_keyword_index_from_chunks()
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_keyword_sync_rebuilds_in_progress_batches(monkeypatch):
+ """Foreground keyword repair uses bounded batches suitable for progress reporting."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t_keyword_progress", embedding_store="")
+ await store.start()
+ store.file_chunks = {str(index): chunk(str(index), f"{index}.md", f"content {index}") for index in range(5)}
+ await store.keyword_index.clear()
+
+ batch_sizes = []
+ original_add_docs = store.keyword_index.add_docs
+
+ async def recording_add_docs(docs):
+ batch_sizes.append(len(docs))
+ await original_add_docs(docs)
+
+ monkeypatch.setattr(local_file_store_module, "_KEYWORD_REBUILD_BATCH_SIZE", 2)
+ monkeypatch.setattr(store.keyword_index, "add_docs", recording_add_docs)
await store._sync_keyword_index_from_chunks()
- assert keyword_index.reset_docs == {"expected": "expected content"}
-
- run(go())
-
-
-def test_keyword_sync_ignores_nonempty_chunk_with_no_indexable_tokens(monkeypatch):
- """A tokenless chunk omitted by BM25 must not trigger a perpetual rebuild."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_tokenless_keyword", embedding_store="")
- await store.start()
- store.file_chunks = {
- "indexed": chunk("indexed", "data.jsonl", "searchable content"),
- "tokenless": chunk("tokenless", "data.jsonl", "\u2028"),
- }
- await store.keyword_index.clear()
- await store.keyword_index.add_docs({cid: item.text for cid, item in store.file_chunks.items()})
- assert set(store.keyword_index.document_ids) == {"indexed"}
-
- async def unexpected_rebuild(_docs):
- raise AssertionError("tokenless BM25 content must not trigger a rebuild")
-
- monkeypatch.setattr(store, "_rebuild_keyword_index", unexpected_rebuild)
- await store._sync_keyword_index_from_chunks()
- await store.close()
-
- run(go())
-
-
-def test_keyword_sync_rebuilds_in_progress_batches(monkeypatch):
- """Foreground keyword repair uses bounded batches suitable for progress reporting."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_keyword_progress", embedding_store="")
- await store.start()
- store.file_chunks = {str(index): chunk(str(index), f"{index}.md", f"content {index}") for index in range(5)}
- await store.keyword_index.clear()
-
- batch_sizes = []
- original_add_docs = store.keyword_index.add_docs
-
- async def recording_add_docs(docs):
- batch_sizes.append(len(docs))
- await original_add_docs(docs)
-
- monkeypatch.setattr(local_file_store_module, "_KEYWORD_REBUILD_BATCH_SIZE", 2)
- monkeypatch.setattr(store.keyword_index, "add_docs", recording_add_docs)
-
- await store._sync_keyword_index_from_chunks()
-
- assert batch_sizes == [2, 2, 1]
- assert set(store.keyword_index.document_ids) == set(store.file_chunks)
- await store.close()
-
- run(go())
-
-
-def test_chunk_persistence_uses_compact_embedding_and_round_trips():
- """Chunk persistence avoids JSON float lists while preserving float16 vectors."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t_compact_embedding", embedding_store="")
- await store.start()
- original = chunk("a", "a.md", "alpha text", source="test")
- original.embedding = np.array([0.25, -1.5, 3.0], dtype=np.float16)
- await set_chunks_with_graph(store, {original.id: original})
- await store.dump()
-
- payload = json.loads(next(read_jsonl_zst(store.chunks_path)))
- assert "embedding" not in payload
- assert isinstance(payload["_embedding_f16_b64"], str)
- assert base64.b64decode(payload["_embedding_f16_b64"]) == original.embedding.astype(" [1,0]).
+ store_a = _new_faiss_store("t_faiss_stale_sidecar")
+ await store_a.start()
+ store_a.embedding_store = FakeEmbeddingStore()
+ store_a._faiss_index = store_a._new_index()
+ await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "alpha topic")])])
+ await store_a.dump()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- # t0: consistent state on disk (c1 = alpha -> [1,0]).
- store_a = _new_faiss_store("t_faiss_stale_sidecar")
- await store_a.start()
- store_a.embedding_store = FakeEmbeddingStore()
- store_a._faiss_index = store_a._new_index()
- await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "alpha topic")])])
- await store_a.dump()
+ # t1: same-ID in-place update (c1 = beta -> [0,1]).
+ await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "beta topic")])])
- # t1: same-ID in-place update (c1 = beta -> [0,1]).
- await store_a.upsert([(node("a.md"), [chunk("c1", "a.md", "beta topic")])])
+ # t2: simulate a crash between the two writes in dump(): only the
+ # parent's JSONL write lands; the sidecar stays at the alpha
+ # generation. (No close() -- the process is presumed dead.)
+ await LocalFileStore._dump_owned_state(store_a)
- # t2: simulate a crash between the two writes in dump(): only the
- # parent's JSONL write lands; the sidecar stays at the alpha
- # generation. (No close() -- the process is presumed dead.)
- await LocalFileStore._dump_owned_state(store_a)
+ # t3: restart. The stale sidecar must be rejected by the digest.
+ store_b = _new_faiss_store("t_faiss_stale_sidecar")
+ await store_b.start()
+ store_b.embedding_store = FakeEmbeddingStore()
+ assert store_b.file_chunks["c1"].text == "beta topic"
- # t3: restart. The stale sidecar must be rejected by the digest.
- store_b = _new_faiss_store("t_faiss_stale_sidecar")
- await store_b.start()
- store_b.embedding_store = FakeEmbeddingStore()
- assert store_b.file_chunks["c1"].text == "beta topic"
+ assert await store_b._try_load_sidecar() is False
+ assert not store_b.faiss_path.exists() # sidecar wiped on rejection
+ store_b._rebuild_index()
- assert await store_b._try_load_sidecar() is False
- assert not store_b.faiss_path.exists() # sidecar wiped on rejection
- store_b._rebuild_index()
-
- # t4: the rebuilt index serves the current generation: a beta query
- # scores ~1.0 instead of 0.0 against the stale alpha vector.
- results = await store_b.vector_search("beta", 5, {})
- assert [c.id for c in results] == ["c1"]
- assert results[0].scores["vector"] > 0.5
- await store_b.close()
-
- run(go())
+ # t4: the rebuilt index serves the current generation: a beta query
+ # scores ~1.0 instead of 0.0 against the stale alpha vector.
+ results = await store_b.vector_search("beta", 5, {})
+ assert [c.id for c in results] == ["c1"]
+ assert results[0].scores["vector"] > 0.5
+ await store_b.close()
-def test_faiss_ef_construction_hot_update_without_rebuild():
+@pytest.mark.asyncio
+async def test_faiss_ef_construction_hot_update_without_rebuild():
"""Reopening with a different hnsw_ef_construction must NOT rebuild: it only
affects future add() edge formation; the live efConstruction is hot-updated."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ # Phase 1: build and persist with efConstruction=40.
+ store_a = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=40)
+ await store_a.start()
+ store_a.embedding_store = FakeEmbeddingStore()
+ store_a._faiss_index = store_a._new_index()
+ await store_a.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ assert store_a._faiss_index.hnsw.efConstruction == 40
+ await store_a.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- # Phase 1: build and persist with efConstruction=40.
- store_a = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=40)
- await store_a.start()
- store_a.embedding_store = FakeEmbeddingStore()
- store_a._faiss_index = store_a._new_index()
- await store_a.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- assert store_a._faiss_index.hnsw.efConstruction == 40
- await store_a.close()
+ # Phase 2: reopen with efConstruction=128 (M unchanged).
+ store_b = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=128)
+ await store_b.start()
+ store_b.embedding_store = FakeEmbeddingStore()
- # Phase 2: reopen with efConstruction=128 (M unchanged).
- store_b = _new_faiss_store("t_faiss_ef_hot", hnsw_ef_construction=128)
- await store_b.start()
- store_b.embedding_store = FakeEmbeddingStore()
+ # Sidecar loads successfully — M matches, only efConstruction differs.
+ assert await store_b._try_load_sidecar() is True
+ assert store_b.faiss_path.exists() # sidecar retained (no rebuild)
- # Sidecar loads successfully — M matches, only efConstruction differs.
- assert await store_b._try_load_sidecar() is True
- assert store_b.faiss_path.exists() # sidecar retained (no rebuild)
+ # efConstruction was hot-updated to the new config value.
+ assert store_b._faiss_index.hnsw.efConstruction == 128
- # efConstruction was hot-updated to the new config value.
- assert store_b._faiss_index.hnsw.efConstruction == 128
-
- # Search still works on the loaded (not rebuilt) index.
- assert [c.id for c in await store_b.vector_search("alpha", 5, {})] == ["a"]
- await store_b.close()
-
- run(go())
+ # Search still works on the loaded (not rebuilt) index.
+ assert [c.id for c in await store_b.vector_search("alpha", 5, {})] == ["a"]
+ await store_b.close()
-def test_faiss_small_index_uses_brute_force_scan():
+@pytest.mark.asyncio
+async def test_faiss_small_index_uses_brute_force_scan():
"""Below the brute-force threshold, vector_search does an exact scan via
index.storage; the HNSW path (_set_ef_search) is used only when large enough."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_brute_force")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_brute_force")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ # 80 chunks with valid date paths so we can test filtered search too.
+ # With M=32 (default): limit=5 -> threshold=sqrt(25)*32=160 > 80
+ # -> brute-force. limit=1 -> threshold=sqrt(5)*32~=72 < 80 -> HNSW.
+ base = datetime.date(2026, 1, 1)
+ files = []
+ for i in range(80):
+ d = base + datetime.timedelta(days=i)
+ path = f"daily/{d.isoformat()}/note.md"
+ files.append((node(path), [chunk(f"c{i}", path, "alpha text")]))
+ await store.upsert(files)
- # 80 chunks with valid date paths so we can test filtered search too.
- # With M=32 (default): limit=5 -> threshold=sqrt(25)*32=160 > 80
- # -> brute-force. limit=1 -> threshold=sqrt(5)*32~=72 < 80 -> HNSW.
- base = datetime.date(2026, 1, 1)
- files = []
- for i in range(80):
- d = base + datetime.timedelta(days=i)
- path = f"daily/{d.isoformat()}/note.md"
- files.append((node(path), [chunk(f"c{i}", path, "alpha text")]))
- await store.upsert(files)
+ # _set_ef_search is only called on the HNSW path. Spying on it
+ # tells us which branch vector_search took.
+ ef_calls: list[int] = []
+ original_set_ef = store._set_ef_search
- # _set_ef_search is only called on the HNSW path. Spying on it
- # tells us which branch vector_search took.
- ef_calls: list[int] = []
- original_set_ef = store._set_ef_search
+ def spy_set_ef(idx, lim):
+ ef_calls.append(lim)
+ original_set_ef(idx, lim)
- def spy_set_ef(idx, lim):
- ef_calls.append(lim)
- original_set_ef(idx, lim)
+ store._set_ef_search = spy_set_ef
- store._set_ef_search = spy_set_ef
+ # ntotal=80, limit=5 -> threshold=sqrt(25)*32=160 -> 80 < 160 -> brute-force.
+ results = await store.vector_search("alpha", 5, {})
+ assert len(results) == 5
+ assert not ef_calls # brute-force path taken
- # ntotal=80, limit=5 -> threshold=sqrt(25)*32=160 -> 80 < 160 -> brute-force.
- results = await store.vector_search("alpha", 5, {})
- assert len(results) == 5
- assert not ef_calls # brute-force path taken
+ # Brute-force + filter: scans all vectors, _collect_hits filters.
+ filt = {"start_date": "2026-01-15", "end_date": "2026-01-17"}
+ results = await store.vector_search("alpha", 5, filt)
+ assert {r.id for r in results} == {"c14", "c15", "c16"}
+ assert not ef_calls # still brute-force
- # Brute-force + filter: scans all vectors, _collect_hits filters.
- filt = {"start_date": "2026-01-15", "end_date": "2026-01-17"}
- results = await store.vector_search("alpha", 5, filt)
- assert {r.id for r in results} == {"c14", "c15", "c16"}
- assert not ef_calls # still brute-force
+ # ntotal=80, limit=1 -> threshold=sqrt(5)*32~=72 -> 80 >= 72 -> HNSW graph search.
+ results = await store.vector_search("alpha", 1, {})
+ assert len(results) == 1
+ assert len(ef_calls) >= 1 # HNSW path taken, efSearch was set
- # ntotal=80, limit=1 -> threshold=sqrt(5)*32~=72 -> 80 >= 72 -> HNSW graph search.
- results = await store.vector_search("alpha", 1, {})
- assert len(results) == 1
- assert len(ef_calls) >= 1 # HNSW path taken, efSearch was set
-
- store._set_ef_search = original_set_ef
- await store.close()
-
- run(go())
+ store._set_ef_search = original_set_ef
+ await store.close()
# -- Async reindex tests -----------------------------------------------------
-def _new_faiss_store(name, **kwargs):
- """Construct a started FAISS store with a fake embedding backend and empty index."""
- try:
- store = FaissLocalFileStore(name=name, embedding_store="", **kwargs)
- except ImportError:
- pytest.skip("faiss is not installed")
- return store
-
-
-async def _settle_reindex(store, timeout=5.0):
- """Wait until no async reindex is pending or in flight."""
- deadline = time.monotonic() + timeout
- while store._reindex_event.is_set() or store._reindex_busy:
- if time.monotonic() > deadline:
- raise AssertionError("async reindex did not settle in time")
- await asyncio.sleep(0.005)
-
-
-def test_faiss_async_reindex_disabled_by_default():
+@pytest.mark.asyncio
+async def test_faiss_async_reindex_disabled_by_default():
"""Default store keeps the synchronous compaction path: no background worker."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_sync_default", max_tombstones=2)
+ assert store.async_reindex is False
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_sync_default", max_tombstones=2)
- assert store.async_reindex is False
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
+ await store.upsert(files)
+ await store.delete([f"n{i}.md" for i in range(3)])
- files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
- await store.upsert(files)
- await store.delete([f"n{i}.md" for i in range(3)])
-
- # Synchronous rebuild ran inline; no background worker was created.
- assert store._reindex_worker_task is None
- assert store._tombstones == set()
- assert set(store._id_to_row) == {"c3"}
- assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"]
- await store.close()
-
- run(go())
+ # Synchronous rebuild ran inline; no background worker was created.
+ assert store._reindex_worker_task is None
+ assert store._tombstones == set()
+ assert set(store._id_to_row) == {"c3"}
+ assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"]
+ await store.close()
-def test_faiss_async_reindex_triggered_by_compaction():
+@pytest.mark.asyncio
+async def test_faiss_async_reindex_triggered_by_compaction():
"""Crossing the tombstone threshold submits a background rebuild whose result
matches a synchronous rebuild (deleted ids gone, tombstones cleared)."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_async_compact", async_reindex=True, max_tombstones=2)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_async_compact", async_reindex=True, max_tombstones=2)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
+ await store.upsert(files)
+ assert store._reindex_worker_task is None # below threshold, nothing submitted yet
- files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
- await store.upsert(files)
- assert store._reindex_worker_task is None # below threshold, nothing submitted yet
+ await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit
+ assert store._reindex_worker_task is not None
+ await _settle_reindex(store)
- await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit
- assert store._reindex_worker_task is not None
- await _settle_reindex(store)
-
- assert set(store._id_to_row) == {"c3"}
- assert store._tombstones == set()
- assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"]
- await store.close()
-
- run(go())
+ assert set(store._id_to_row) == {"c3"}
+ assert store._tombstones == set()
+ assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["c3"]
+ await store.close()
-def test_faiss_async_reindex_no_lost_writes_during_build():
+@pytest.mark.asyncio
+async def test_faiss_async_reindex_no_lost_writes_during_build():
"""Writes that land while an async rebuild is in flight are not lost: a
follow-up rebuild folds them in (eventual consistency)."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_async_nolost", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_async_nolost", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ started = threading.Event()
+ release = threading.Event()
+ real_build = store._build_index_blocking
- started = threading.Event()
- release = threading.Event()
- real_build = store._build_index_blocking
+ def gated_build(dim, vectors):
+ started.set()
+ release.wait()
+ return real_build(dim, vectors)
- def gated_build(dim, vectors):
- started.set()
- release.wait()
- return real_build(dim, vectors)
+ store._build_index_blocking = gated_build
- store._build_index_blocking = gated_build
+ store._submit_reindex() # snapshot == {a}
+ while not started.is_set():
+ await asyncio.sleep(0.005)
- store._submit_reindex() # snapshot == {a}
- while not started.is_set():
- await asyncio.sleep(0.005)
+ # Concurrent writes on the live index while the build is blocked:
+ await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])]) # brand new
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])]) # changed text
- # Concurrent writes on the live index while the build is blocked:
- await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])]) # brand new
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])]) # changed text
+ release.set()
+ await _settle_reindex(store)
- release.set()
- await _settle_reindex(store)
-
- # After the follow-up rebuild the index reflects both concurrent writes;
- # the changed chunk now embeds as "beta".
- assert set(store._id_to_row) == {"a", "b"}
- assert {c.id for c in await store.vector_search("beta", 5, {})} == {
- "a",
- "b",
- }
- await store.close()
-
- run(go())
+ # After the follow-up rebuild the index reflects both concurrent writes;
+ # the changed chunk now embeds as "beta".
+ assert set(store._id_to_row) == {"a", "b"}
+ assert {c.id for c in await store.vector_search("beta", 5, {})} == {
+ "a",
+ "b",
+ }
+ await store.close()
-def test_faiss_async_reindex_single_worker_coalesces():
+@pytest.mark.asyncio
+async def test_faiss_async_reindex_single_worker_coalesces():
"""Only one reindex runs at a time; repeated submissions coalesce and the
worker stays a single long-lived task."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_async_single", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_async_single", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ started = threading.Event()
+ release = threading.Event()
+ active = {"n": 0}
+ peak = {"n": 0}
+ real_build = store._build_index_blocking
- started = threading.Event()
- release = threading.Event()
- active = {"n": 0}
- peak = {"n": 0}
- real_build = store._build_index_blocking
+ def gated_build(dim, vectors):
+ active["n"] += 1
+ peak["n"] = max(peak["n"], active["n"])
+ started.set()
+ release.wait()
+ try:
+ return real_build(dim, vectors)
+ finally:
+ active["n"] -= 1
- def gated_build(dim, vectors):
- active["n"] += 1
- peak["n"] = max(peak["n"], active["n"])
- started.set()
- release.wait()
- try:
- return real_build(dim, vectors)
- finally:
- active["n"] -= 1
+ store._build_index_blocking = gated_build
- store._build_index_blocking = gated_build
+ store._submit_reindex()
+ while not started.is_set():
+ await asyncio.sleep(0.005)
+ worker = store._reindex_worker_task
+ # Several more submissions while the first build is blocked collapse into
+ # the flag rather than spawning parallel builds or a second worker.
+ for _ in range(5):
store._submit_reindex()
- while not started.is_set():
- await asyncio.sleep(0.005)
- worker = store._reindex_worker_task
+ assert store._reindex_worker_task is worker
- # Several more submissions while the first build is blocked collapse into
- # the flag rather than spawning parallel builds or a second worker.
- for _ in range(5):
- store._submit_reindex()
- assert store._reindex_worker_task is worker
+ release.set()
+ await _settle_reindex(store)
- release.set()
- await _settle_reindex(store)
-
- assert peak["n"] == 1 # never two builds at once
- assert set(store._id_to_row) == {"a"}
- await store.close()
-
- run(go())
+ assert peak["n"] == 1 # never two builds at once
+ assert set(store._id_to_row) == {"a"}
+ await store.close()
-def test_faiss_async_reindex_cancelled_on_close():
+@pytest.mark.asyncio
+async def test_faiss_async_reindex_cancelled_on_close():
"""close() stops an in-flight reindex without hanging on the worker thread."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_async_close", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_async_close", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ started = threading.Event()
- started = threading.Event()
+ def slow_build(_dim, _vectors):
+ started.set()
+ while not store._closing:
+ time.sleep(0.005)
- def slow_build(_dim, _vectors):
- started.set()
- while not store._closing:
- time.sleep(0.005)
+ store._build_index_blocking = slow_build
+ store._submit_reindex()
+ while not started.is_set():
+ await asyncio.sleep(0.005)
- store._build_index_blocking = slow_build
- store._submit_reindex()
- while not started.is_set():
- await asyncio.sleep(0.005)
-
- await store.close() # sets _closing, cancels the worker; the build thread exits
- assert store._reindex_worker_task is None
-
- run(go())
+ await store.close() # sets _closing, cancels the worker; the build thread exits
+ assert store._reindex_worker_task is None
-def test_faiss_close_does_not_leave_orphan_reindex():
+@pytest.mark.asyncio
+async def test_faiss_close_does_not_leave_orphan_reindex():
"""The final dump in _close() must not submit a background reindex."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_close_orphan", async_reindex=True, max_tombstones=2)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_close_orphan", async_reindex=True, max_tombstones=2)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
+ await store.upsert(files)
- files = [(node(f"n{i}.md"), [chunk(f"c{i}", f"n{i}.md", "alpha text")]) for i in range(4)]
- await store.upsert(files)
+ # Block the build so the reindex is still in flight (tombstones uncompacted)
+ # when close runs.
+ started = threading.Event()
- # Block the build so the reindex is still in flight (tombstones uncompacted)
- # when close runs.
- started = threading.Event()
+ def slow_build(_dim, _vectors):
+ started.set()
+ while not store._closing:
+ time.sleep(0.005)
- def slow_build(_dim, _vectors):
- started.set()
- while not store._closing:
- time.sleep(0.005)
+ store._build_index_blocking = slow_build
+ await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit
+ while not started.is_set():
+ await asyncio.sleep(0.005)
+ assert len(store._tombstones) >= store.max_tombstones # not compacted yet
- store._build_index_blocking = slow_build
- await store.delete([f"n{i}.md" for i in range(3)]) # 3 tombstones >= 2 -> submit
- while not started.is_set():
- await asyncio.sleep(0.005)
- assert len(store._tombstones) >= store.max_tombstones # not compacted yet
-
- # Closing cancels the in-flight worker; the _closing guard stops the
- # final dump from submitting an orphan reindex.
- await store.close()
- assert store._reindex_worker_task is None
-
- run(go())
+ # Closing cancels the in-flight worker; the _closing guard stops the
+ # final dump from submitting an orphan reindex.
+ await store.close()
+ assert store._reindex_worker_task is None
-def test_faiss_async_close_persists_stale_snapshot_on_same_id_update():
+@pytest.mark.asyncio
+async def test_faiss_async_close_persists_stale_snapshot_on_same_id_update():
"""Regression: close() during a follow-up rebuild can persist a stale snapshot.
Bug: async rebuild snapshots alpha; "a" is updated to beta; the first build
@@ -2263,136 +2037,201 @@ def test_faiss_async_close_persists_stale_snapshot_on_same_id_update():
Expected after fix: searching with the beta vector scores ~1.0.
"""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_stale_close", async_reindex=True)
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_stale_close", async_reindex=True)
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
+ # Chunk "a" with alpha text -> embedding [1.0, 0.0].
+ await store.upsert([(node("note.md"), [chunk("a", "note.md", "alpha text")])])
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- # Chunk "a" with alpha text -> embedding [1.0, 0.0].
- await store.upsert([(node("note.md"), [chunk("a", "note.md", "alpha text")])])
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ first_started = threading.Event()
+ release_first = threading.Event()
+ second_started = threading.Event()
+ real_build = store._build_index_blocking
+ build_count = {"n": 0}
- first_started = threading.Event()
- release_first = threading.Event()
- second_started = threading.Event()
- real_build = store._build_index_blocking
- build_count = {"n": 0}
-
- def gated_build(dim, vectors):
- build_count["n"] += 1
- if build_count["n"] == 1:
- # First build: hold the alpha snapshot.
- first_started.set()
- release_first.wait()
- else:
- # Follow-up build: signal started, then hold until close().
- second_started.set()
- while not store._closing:
- time.sleep(0.005)
- return real_build(dim, vectors)
-
- store._build_index_blocking = gated_build
-
- # Step 1: submit the first rebuild (snapshot == {a: alpha}).
- store._submit_reindex()
- while not first_started.is_set():
- await asyncio.sleep(0.005)
-
- await store.upsert([(node("note.md"), [chunk("a", "note.md", "beta text")])])
- release_first.set()
- while not second_started.is_set():
- await asyncio.sleep(0.005)
- await store.close()
- assert store._reindex_worker_task is None
-
- # Step 6: reopen and verify the persisted state.
- reopened = _new_faiss_store("t_faiss_stale_close", async_reindex=True)
- await reopened.start()
- reopened.embedding_store = FakeEmbeddingStore()
-
- # The authoritative chunk JSONL has "beta text".
- assert reopened.file_chunks["a"].text == "beta text"
-
- # The stale sidecar is accepted because the ID set is unchanged.
- assert await reopened._try_load_sidecar() is True
-
- results = await reopened.vector_search("beta", 5, {})
- assert len(results) == 1
- assert results[0].id == "a"
- score = results[0].scores["vector"]
- assert score > 0.5, (
- f"BUG: persisted FAISS index has stale alpha vector; " f"beta query scored {score:.4f} instead of ~1.0"
- )
- await reopened.close()
-
- run(go())
-
-
-def test_faiss_clear_waits_for_in_flight_dump():
- """clear() serializes with dump() through _faiss_dump_lock."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_clear_lock")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
-
- first_started = asyncio.Event()
- release = asyncio.Event()
- original_write_sidecar = store._write_sidecar
-
- async def blocking_write_sidecar():
+ def gated_build(dim, vectors):
+ build_count["n"] += 1
+ if build_count["n"] == 1:
+ # First build: hold the alpha snapshot.
first_started.set()
- await release.wait()
+ release_first.wait()
+ else:
+ # Follow-up build: signal started, then hold until close().
+ second_started.set()
+ while not store._closing:
+ time.sleep(0.005)
+ return real_build(dim, vectors)
- store._write_sidecar = blocking_write_sidecar
- dump_task = asyncio.create_task(store.dump())
- await first_started.wait()
+ store._build_index_blocking = gated_build
- clear_task = asyncio.create_task(store.clear())
- await asyncio.sleep(0.02)
- assert not clear_task.done() # blocked on _faiss_dump_lock held by dump
+ # Step 1: submit the first rebuild (snapshot == {a: alpha}).
+ store._submit_reindex()
+ while not first_started.is_set():
+ await asyncio.sleep(0.005)
- release.set()
- await asyncio.gather(dump_task, clear_task)
- assert store._id_to_row == {}
+ await store.upsert([(node("note.md"), [chunk("a", "note.md", "beta text")])])
+ release_first.set()
+ while not second_started.is_set():
+ await asyncio.sleep(0.005)
+ await store.close()
+ assert store._reindex_worker_task is None
- store._write_sidecar = original_write_sidecar
- await store.close()
+ # Step 6: reopen and verify the persisted state.
+ reopened = _new_faiss_store("t_faiss_stale_close", async_reindex=True)
+ await reopened.start()
+ reopened.embedding_store = FakeEmbeddingStore()
- run(go())
+ # The authoritative chunk JSONL has "beta text".
+ assert reopened.file_chunks["a"].text == "beta text"
+
+ # The stale sidecar is accepted because the ID set is unchanged.
+ assert await reopened._try_load_sidecar() is True
+
+ results = await reopened.vector_search("beta", 5, {})
+ assert len(results) == 1
+ assert results[0].id == "a"
+ score = results[0].scores["vector"]
+ assert score > 0.5, (
+ f"BUG: persisted FAISS index has stale alpha vector; " f"beta query scored {score:.4f} instead of ~1.0"
+ )
+ await reopened.close()
-def test_faiss_delete_queries_graph_once():
+@pytest.mark.asyncio
+async def test_faiss_clear_waits_for_in_flight_dump():
+ """clear() serializes with dump() through _faiss_dump_lock."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_clear_lock")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+
+ first_started = asyncio.Event()
+ release = asyncio.Event()
+ original_write_sidecar = store._write_sidecar
+
+ async def blocking_write_sidecar():
+ first_started.set()
+ await release.wait()
+
+ store._write_sidecar = blocking_write_sidecar
+ dump_task = asyncio.create_task(store.dump())
+ await first_started.wait()
+
+ clear_task = asyncio.create_task(store.clear())
+ await asyncio.sleep(0.02)
+ assert not clear_task.done() # blocked on _faiss_dump_lock held by dump
+
+ release.set()
+ await asyncio.gather(dump_task, clear_task)
+ assert store._id_to_row == {}
+
+ store._write_sidecar = original_write_sidecar
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_faiss_delete_queries_graph_once():
"""delete() resolves nodes once and reuses them (no redundant get_nodes)."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_faiss_store("t_faiss_delete_once")
+ await store.start()
+ store.embedding_store = FakeEmbeddingStore()
+ store._faiss_index = store._new_index()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_faiss_store("t_faiss_delete_once")
- await store.start()
- store.embedding_store = FakeEmbeddingStore()
- store._faiss_index = store._new_index()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ calls: list = []
+ original_get_nodes = store.file_graph.get_nodes
- calls: list = []
- original_get_nodes = store.file_graph.get_nodes
+ async def counting_get_nodes(paths=None):
+ calls.append(paths)
+ return await original_get_nodes(paths)
- async def counting_get_nodes(paths=None):
- calls.append(paths)
- return await original_get_nodes(paths)
+ store.file_graph.get_nodes = counting_get_nodes
+ await store.delete("a.md")
- store.file_graph.get_nodes = counting_get_nodes
- await store.delete("a.md")
+ assert len(calls) == 1
+ assert "a" not in store._id_to_row
- assert len(calls) == 1
- assert "a" not in store._id_to_row
+ store.file_graph.get_nodes = original_get_nodes
+ await store.close()
- store.file_graph.get_nodes = original_get_nodes
- await store.close()
- run(go())
+class OverLimitError(Exception):
+ """OpenAI-compatible 400 raised for an item over the provider's per-item window."""
+
+ status_code = 400
+ body = {"error": {"message": "the input length exceeds the context length"}}
+
+
+class OverLimitAsEmbedding:
+ """Reject any text longer than ``max_chars`` and record every text it did embed."""
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+
+ def __init__(self, max_chars: int):
+ self.max_chars = max_chars
+ self.embedded_texts: list[str] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ if any(len(text) > self.max_chars for text in texts):
+ raise OverLimitError("the input length exceeds the context length")
+ self.embedded_texts.extend(texts)
+ return [[1.0, 0.0] for _ in texts]
+
+
+@pytest.mark.parametrize("store_factory", [_new_local_store, _new_faiss_store, _new_zvec_store])
+@pytest.mark.asyncio
+async def test_embedding_reindex_completes_when_a_chunk_exceeds_the_provider_limit(store_factory):
+ """An over-limit chunk must not block the embedding gate forever.
+
+ Reproduces the reported dead end: the provider rejects one chunk as longer than its
+ token window, so the chunk never gets a vector, the scoped reindex keeps raising and
+ the gate never clears. A bounded input-shortening retry has to recover that chunk
+ instead, so reindex completes and the gate opens with every vector in place.
+ """
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = store_factory(name="t_reindex_over_limit_chunk")
+ await store.start()
+ long_text = "x" * 64
+ await set_chunks_with_graph(
+ store,
+ {
+ "good": chunk("good", "a.md", "alpha text"),
+ "long": chunk("long", "b.md", long_text),
+ },
+ )
+ provider = OverLimitAsEmbedding(max_chars=32)
+ embedding = LocalEmbeddingStore(
+ name="t_reindex_over_limit_chunk_embedding",
+ enable_cache=False,
+ max_input_length=64,
+ )
+ embedding.as_embedding = provider
+ store.embedding_store = embedding
+ _ensure_zvec_collection(store)
+ if isinstance(store, FaissLocalFileStore):
+ store._rebuild_index()
+ elif isinstance(store, ZvecLocalFileStore):
+ store._rebuild_collection()
+ await store.require_embedding_rebuild()
+
+ result = await store.reindex("embedding")
+
+ assert result == {"indexed": 2, "scope": "embedding"}
+ assert store._embedding_rebuild_pending is False
+ # The health probe also reaches the provider, so only the chunk texts matter here.
+ assert "alpha text" in provider.embedded_texts
+ assert "x" * 32 in provider.embedded_texts
+ assert long_text not in provider.embedded_texts
+
+ await store.close()
diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py
index f95504d8..e742e6ff 100644
--- a/tests/unit/test_frontmatter_steps.py
+++ b/tests/unit/test_frontmatter_steps.py
@@ -10,7 +10,7 @@ probed path in not-found errors instead of the caller's raw input.
# pylint: disable=protected-access
-import os
+from contextlib import chdir as temp_chdir
import tempfile
from pathlib import Path
@@ -23,22 +23,6 @@ from reme.steps.file_io.frontmatter_read import FrontmatterReadStep
from reme.steps.file_io.frontmatter_update import FrontmatterUpdateStep
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def _seed(workspace: Path, rel: str, body: str) -> Path:
target = workspace / rel
target.parent.mkdir(parents=True, exist_ok=True)
diff --git a/tests/unit/test_injected_job_kwargs.py b/tests/unit/test_injected_job_kwargs.py
index 5da892e8..35cd82f4 100644
--- a/tests/unit/test_injected_job_kwargs.py
+++ b/tests/unit/test_injected_job_kwargs.py
@@ -267,7 +267,3 @@ def test_configs_define_original_jobs_without_daily_variants():
{"backend": "auto_memory_cc_step"},
{"backend": "auto_tag_step"},
]
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/unit/test_jsonl_file_chunker.py b/tests/unit/test_jsonl_file_chunker.py
index 72720433..42d65167 100644
--- a/tests/unit/test_jsonl_file_chunker.py
+++ b/tests/unit/test_jsonl_file_chunker.py
@@ -45,7 +45,6 @@ def test_empty_file():
node, chunks = _run(chunker.chunk(path))
assert len(chunks) == 0
assert node.links == []
- print("✓ test_empty_file passed")
finally:
os.unlink(path)
@@ -59,7 +58,6 @@ def test_blank_lines_only():
chunker = JsonlFileChunker()
_, chunks = _run(chunker.chunk(path))
assert len(chunks) == 0
- print("✓ test_blank_lines_only passed")
finally:
os.unlink(path)
@@ -75,7 +73,6 @@ def test_single_line():
assert chunks[0].start_line == 1
assert chunks[0].end_line == 1
assert lines[0] in chunks[0].text
- print("✓ test_single_line passed")
finally:
os.unlink(path)
@@ -106,7 +103,6 @@ def test_all_lines_fit_in_one_chunk():
assert len(chunks) == 1
assert chunks[0].start_line == 1
assert chunks[0].end_line == 5
- print("✓ test_all_lines_fit_in_one_chunk passed")
finally:
os.unlink(path)
@@ -148,7 +144,6 @@ def test_multiple_chunks_no_overlap():
assert chunks[0].start_line == 1
assert chunks[-1].end_line == 10
print(f" Created {len(chunks)} chunks")
- print("✓ test_multiple_chunks_no_overlap passed")
finally:
os.unlink(path)
@@ -165,7 +160,6 @@ def test_line_aligned_no_intra_line_split():
# Each chunk text should be a concatenation of whole lines.
text_lines = c.text.splitlines()
assert len(text_lines) == (c.end_line - c.start_line + 1)
- print("✓ test_line_aligned_no_intra_line_split passed")
finally:
os.unlink(path)
@@ -193,7 +187,6 @@ def test_overlap_lines_shared():
# Last chunk still reaches the end of the file.
assert chunks[-1].end_line == 12
print(f" Created {len(chunks)} chunks with overlap")
- print("✓ test_overlap_lines_shared passed")
finally:
os.unlink(path)
@@ -207,7 +200,6 @@ def test_zero_overlap():
_, chunks = _run(chunker.chunk(path))
for i in range(len(chunks) - 1):
assert chunks[i].end_line < chunks[i + 1].start_line
- print("✓ test_zero_overlap passed")
finally:
os.unlink(path)
@@ -228,7 +220,6 @@ def test_overlap_capped_by_max_overlap_chars():
overlap_lines = list(range(overlap_start, overlap_end + 1))
overlap_text = "".join(lines[ln - 1] for ln in overlap_lines)
assert len(overlap_text) <= max_overlap, f"Overlap size {len(overlap_text)} > {max_overlap}"
- print("✓ test_overlap_capped_by_max_overlap_chars passed")
finally:
os.unlink(path)
@@ -255,7 +246,6 @@ def test_single_line_exceeds_max():
break
assert found, "Long line was not found in any chunk"
assert len(chunks) >= 2 # At least some splitting happened.
- print("✓ test_single_line_exceeds_max passed")
finally:
os.unlink(path)
@@ -290,7 +280,6 @@ def test_byte_mode():
f"than char mode ({len(chunks_chars)}) for CJK text"
)
print(f" char mode: {len(chunks_chars)} chunks, byte mode: {len(chunks_bytes)} chunks")
- print("✓ test_byte_mode passed")
finally:
os.unlink(path)
@@ -302,7 +291,6 @@ def test_invalid_mode_raises():
assert False, "Should have raised ValueError"
except ValueError:
pass
- print("✓ test_invalid_mode_raises passed")
# ---------------------------------------------------------------------------
@@ -322,7 +310,6 @@ def test_file_chunk_properties():
assert c.end_line >= c.start_line
assert c.id and len(c.id) > 0
assert c.text.strip()
- print("✓ test_file_chunk_properties passed")
finally:
os.unlink(path)
@@ -337,7 +324,6 @@ def test_file_node_properties():
assert node.st_mtime > 0
assert node.links == []
assert node.chunk_ids == [c.id for c in chunks]
- print("✓ test_file_node_properties passed")
finally:
os.unlink(path)
@@ -352,7 +338,6 @@ def test_hash_id_deterministic():
c2 = JsonlFileChunker(max_chars=100)
_, chunks2 = _run(c2.chunk(path))
assert [c.id for c in chunks1] == [c.id for c in chunks2]
- print("✓ test_hash_id_deterministic passed")
finally:
os.unlink(path)
@@ -366,14 +351,12 @@ def test_max_chars_floor():
"""max_chars is clamped to at least 64."""
c = JsonlFileChunker(max_chars=1)
assert c.max_chars == 64
- print("✓ test_max_chars_floor passed")
def test_max_overlap_floor():
"""Negative max_overlap_chars is clamped to 0."""
c = JsonlFileChunker(max_overlap_chars=-10)
assert c.max_overlap_chars == 0
- print("✓ test_max_overlap_floor passed")
def test_full_coverage():
@@ -387,7 +370,6 @@ def test_full_coverage():
for c in chunks:
covered.update(range(c.start_line, c.end_line + 1))
assert covered == set(range(1, 26)), f"Missing lines: {set(range(1, 26)) - covered}"
- print("✓ test_full_coverage passed")
finally:
os.unlink(path)
@@ -406,7 +388,6 @@ def test_chunk_lines_algorithm_simple():
# 3 lines = 63 chars ≤ 64 → chunk 1: (0,3). 4th line alone → chunk 2: (3,4).
ranges = chunker._chunk_lines(lines)
assert ranges == [(0, 3), (3, 4)], f"Got {ranges}"
- print("✓ test_chunk_lines_algorithm_simple passed")
def test_chunk_lines_algorithm_with_overlap():
@@ -422,34 +403,8 @@ def test_chunk_lines_algorithm_with_overlap():
assert ranges[i][1] >= ranges[i + 1][0], f"No overlap between range {i} {ranges[i]} and {i+1} {ranges[i+1]}"
# Last range reaches end.
assert ranges[-1][1] == 6
- print("✓ test_chunk_lines_algorithm_with_overlap passed")
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
-
-
-if __name__ == "__main__":
- test_empty_file()
- test_blank_lines_only()
- test_single_line()
- test_unicode_line_separator_inside_json_string_is_not_a_record_boundary()
- test_all_lines_fit_in_one_chunk()
- test_multiple_chunks_no_overlap()
- test_line_aligned_no_intra_line_split()
- test_overlap_lines_shared()
- test_zero_overlap()
- test_overlap_capped_by_max_overlap_chars()
- test_single_line_exceeds_max()
- test_byte_mode()
- test_invalid_mode_raises()
- test_file_chunk_properties()
- test_file_node_properties()
- test_hash_id_deterministic()
- test_max_chars_floor()
- test_max_overlap_floor()
- test_full_coverage()
- test_chunk_lines_algorithm_simple()
- test_chunk_lines_algorithm_with_overlap()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_keyword_index.py b/tests/unit/test_keyword_index.py
index 9d135284..4a62e6d1 100644
--- a/tests/unit/test_keyword_index.py
+++ b/tests/unit/test_keyword_index.py
@@ -8,12 +8,15 @@ single-char ASCII words dropped).
# pylint: disable=protected-access
+from contextlib import chdir as temp_chdir
import asyncio
import os
import tempfile
import threading
import warnings
+import pytest
+
from reme.components.keyword_index import BM25Index
from reme.components.tokenizer import RegexTokenizer
@@ -26,22 +29,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resou
# --------------------------------------------------------------------------- #
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
async def create_bm25(
k1: float = 1.5,
b: float = 0.75,
@@ -59,58 +46,44 @@ async def create_bm25(
return bm25
-def run(coro):
- """Tiny shorthand to avoid repeating asyncio.run wrappers."""
- return asyncio.run(coro)
-
-
# --------------------------------------------------------------------------- #
# Initialisation & lifecycle #
# --------------------------------------------------------------------------- #
-def test_index_file_raises_when_tokenizer_is_none():
+@pytest.mark.asyncio
+async def test_index_file_raises_when_tokenizer_is_none():
"""index_file must raise when tokenizer is explicitly None."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = BM25Index()
- bm25.tokenizer = None
- try:
- _ = bm25.index_file
- except RuntimeError:
- return
- raise AssertionError("expected RuntimeError when tokenizer is None")
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = BM25Index()
+ bm25.tokenizer = None
+ try:
+ _ = bm25.index_file
+ except RuntimeError:
+ return
+ raise AssertionError("expected RuntimeError when tokenizer is None")
-def test_start_close_lifecycle():
+@pytest.mark.asyncio
+async def test_start_close_lifecycle():
"""start/close toggles is_started and runs underlying tokenizer."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- assert bm25.is_started
- assert bm25.tokenizer is not None
- await bm25.close()
- assert not bm25.is_started
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ assert bm25.is_started
+ assert bm25.tokenizer is not None
+ await bm25.close()
+ assert not bm25.is_started
-def test_index_file_path_includes_tokenizer_and_version():
+@pytest.mark.asyncio
+async def test_index_file_path_includes_tokenizer_and_version():
"""index_file path embeds tokenizer name + index_version."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- path = str(bm25.index_file)
- assert "bm25_BM25Index_regex_" in path
- assert path.endswith("_v1.pkl")
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ path = str(bm25.index_file)
+ assert "bm25_BM25Index_regex_" in path
+ assert path.endswith("_v1.pkl")
+ await bm25.close()
# --------------------------------------------------------------------------- #
@@ -118,168 +91,141 @@ def test_index_file_path_includes_tokenizer_and_version():
# --------------------------------------------------------------------------- #
-def test_add_empty_dict_noop():
+@pytest.mark.asyncio
+async def test_add_empty_dict_noop():
"""Adding an empty dict must not touch state."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({})
- assert bm25.n_docs == 0
- assert bm25.vocab == {}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({})
+ assert bm25.n_docs == 0
+ assert bm25.vocab == {}
+ await bm25.close()
-def test_add_single_doc():
+@pytest.mark.asyncio
+async def test_add_single_doc():
"""A single doc populates length, vocab and metadata."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world"})
- assert bm25.n_docs == 1
- assert bm25.total_len == 2 # 'hello', 'world'
- assert bm25.avg_len == 2.0
- assert set(bm25.vocab) == {"hello", "world"}
- assert bm25.document_ids == {"d1"}
- assert "d1" in bm25.doc_meta
- assert bm25.doc_meta["d1"]["len"] == 2
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world"})
+ assert bm25.n_docs == 1
+ assert bm25.total_len == 2 # 'hello', 'world'
+ assert bm25.avg_len == 2.0
+ assert set(bm25.vocab) == {"hello", "world"}
+ assert bm25.document_ids == {"d1"}
+ assert "d1" in bm25.doc_meta
+ assert bm25.doc_meta["d1"]["len"] == 2
+ await bm25.close()
-def test_add_multiple_docs_and_inverted_index():
+@pytest.mark.asyncio
+async def test_add_multiple_docs_and_inverted_index():
"""Inverted index lists postings for every term across multiple docs."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "hello world",
- "d2": "hello python",
- "d3": "world python",
- },
- )
- assert bm25.n_docs == 3
- inv = bm25.inverted_index
- tid_hello = bm25.vocab["hello"]
- tid_world = bm25.vocab["world"]
- tid_python = bm25.vocab["python"]
- assert set(inv[tid_hello]) == {"d1", "d2"}
- assert set(inv[tid_world]) == {"d1", "d3"}
- assert set(inv[tid_python]) == {"d2", "d3"}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "hello world",
+ "d2": "hello python",
+ "d3": "world python",
+ },
+ )
+ assert bm25.n_docs == 3
+ inv = bm25.inverted_index
+ tid_hello = bm25.vocab["hello"]
+ tid_world = bm25.vocab["world"]
+ tid_python = bm25.vocab["python"]
+ assert set(inv[tid_hello]) == {"d1", "d2"}
+ assert set(inv[tid_world]) == {"d1", "d3"}
+ assert set(inv[tid_python]) == {"d2", "d3"}
+ await bm25.close()
-def test_add_doc_empty_or_whitespace_is_skipped():
+@pytest.mark.asyncio
+async def test_add_doc_empty_or_whitespace_is_skipped():
"""Empty / whitespace-only content yields no tokens and is silently dropped."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "", "d2": " ", "d3": "\n\t"})
- assert bm25.n_docs == 0
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "", "d2": " ", "d3": "\n\t"})
+ assert bm25.n_docs == 0
+ await bm25.close()
-def test_update_existing_doc_swaps_content():
+@pytest.mark.asyncio
+async def test_update_existing_doc_swaps_content():
"""Re-adding same doc_id replaces tokens; old terms no longer match it."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world python"})
+ old_len = bm25.total_len
+ await bm25.add_docs({"d1": "java"})
+ assert bm25.n_docs == 1
+ assert bm25.total_len != old_len
+ assert bm25.doc_meta["d1"]["len"] == 1
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world python"})
- old_len = bm25.total_len
- await bm25.add_docs({"d1": "java"})
- assert bm25.n_docs == 1
- assert bm25.total_len != old_len
- assert bm25.doc_meta["d1"]["len"] == 1
-
- # Old term must no longer return d1.
- assert "d1" not in await bm25.retrieve("hello", limit=5)
- # New term does.
- assert "d1" in await bm25.retrieve("java", limit=5)
- await bm25.close()
-
- run(go())
+ # Old term must no longer return d1.
+ assert "d1" not in await bm25.retrieve("hello", limit=5)
+ # New term does.
+ assert "d1" in await bm25.retrieve("java", limit=5)
+ await bm25.close()
-def test_delete_single_doc():
+@pytest.mark.asyncio
+async def test_delete_single_doc():
"""delete_docs removes a single doc from retrieval and meta."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world", "d2": "hello python"})
+ assert bm25.n_docs == 2
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world", "d2": "hello python"})
- assert bm25.n_docs == 2
+ await bm25.delete_docs(["d1"])
+ assert bm25.n_docs == 1
+ assert "d1" not in bm25.doc_meta
+ assert "d2" in bm25.doc_meta
- await bm25.delete_docs(["d1"])
- assert bm25.n_docs == 1
- assert "d1" not in bm25.doc_meta
- assert "d2" in bm25.doc_meta
-
- results = await bm25.retrieve("hello", limit=2)
- assert "d1" not in results
- assert "d2" in results
- await bm25.close()
-
- run(go())
+ results = await bm25.retrieve("hello", limit=2)
+ assert "d1" not in results
+ assert "d2" in results
+ await bm25.close()
-def test_delete_multiple_docs():
+@pytest.mark.asyncio
+async def test_delete_multiple_docs():
"""delete_docs handles a batch list."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({f"d{i}": "hello world" for i in range(5)})
+ assert bm25.n_docs == 5
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({f"d{i}": "hello world" for i in range(5)})
- assert bm25.n_docs == 5
-
- await bm25.delete_docs(["d0", "d2", "d4"])
- assert bm25.n_docs == 2
- assert set(bm25.doc_meta) == {"d1", "d3"}
- await bm25.close()
-
- run(go())
+ await bm25.delete_docs(["d0", "d2", "d4"])
+ assert bm25.n_docs == 2
+ assert set(bm25.doc_meta) == {"d1", "d3"}
+ await bm25.close()
-def test_delete_nonexistent_is_noop():
+@pytest.mark.asyncio
+async def test_delete_nonexistent_is_noop():
"""Deleting unknown doc_ids must not raise."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello"})
- await bm25.delete_docs(["nope", "still_nope"])
- assert bm25.n_docs == 1
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello"})
+ await bm25.delete_docs(["nope", "still_nope"])
+ assert bm25.n_docs == 1
+ await bm25.close()
-def test_re_add_after_delete():
+@pytest.mark.asyncio
+async def test_re_add_after_delete():
"""Adding a doc_id back after deletion yields a fresh idx and is retrievable."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello"})
- await bm25.delete_docs(["d1"])
- assert bm25.n_docs == 0
- await bm25.add_docs({"d1": "world"})
- assert bm25.n_docs == 1
- assert "d1" in await bm25.retrieve("world", limit=1)
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello"})
+ await bm25.delete_docs(["d1"])
+ assert bm25.n_docs == 0
+ await bm25.add_docs({"d1": "world"})
+ assert bm25.n_docs == 1
+ assert "d1" in await bm25.retrieve("world", limit=1)
+ await bm25.close()
# --------------------------------------------------------------------------- #
@@ -287,147 +233,123 @@ def test_re_add_after_delete():
# --------------------------------------------------------------------------- #
-def test_retrieve_empty_index():
+@pytest.mark.asyncio
+async def test_retrieve_empty_index():
"""Retrieving from an empty index returns {}."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- assert await bm25.retrieve("python", limit=3) == {}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ assert await bm25.retrieve("python", limit=3) == {}
+ await bm25.close()
-def test_retrieve_empty_or_unknown_query():
+@pytest.mark.asyncio
+async def test_retrieve_empty_or_unknown_query():
"""Empty queries and out-of-vocab queries both return {}."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world"})
- assert await bm25.retrieve("", limit=3) == {}
- assert await bm25.retrieve("zzzunknownxyz", limit=3) == {}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world"})
+ assert await bm25.retrieve("", limit=3) == {}
+ assert await bm25.retrieve("zzzunknownxyz", limit=3) == {}
+ await bm25.close()
-def test_retrieve_limit_caps_results():
+@pytest.mark.asyncio
+async def test_retrieve_limit_caps_results():
"""retrieve honours `limit`."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({f"d{i}": f"python lang {i}" for i in range(10)})
- assert len(await bm25.retrieve("python", limit=3)) == 3
- assert len(await bm25.retrieve("python", limit=5)) == 5
- # limit greater than matches: bounded by positive matches.
- assert len(await bm25.retrieve("python", limit=99)) == 10
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({f"d{i}": f"python lang {i}" for i in range(10)})
+ assert len(await bm25.retrieve("python", limit=3)) == 3
+ assert len(await bm25.retrieve("python", limit=5)) == 5
+ # limit greater than matches: bounded by positive matches.
+ assert len(await bm25.retrieve("python", limit=99)) == 10
+ await bm25.close()
-def test_retrieve_score_ordering_by_tf():
+@pytest.mark.asyncio
+async def test_retrieve_score_ordering_by_tf():
"""A doc with higher term frequency for the query token outranks others."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "high": "python python python",
- "mid": "python python other",
- "low": "python alpha beta",
- },
- )
- results = await bm25.retrieve("python", limit=3)
- assert list(results.keys()) == ["high", "mid", "low"]
- scores = list(results.values())
- assert scores[0] >= scores[1] >= scores[2]
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "high": "python python python",
+ "mid": "python python other",
+ "low": "python alpha beta",
+ },
+ )
+ results = await bm25.retrieve("python", limit=3)
+ assert list(results.keys()) == ["high", "mid", "low"]
+ scores = list(results.values())
+ assert scores[0] >= scores[1] >= scores[2]
+ await bm25.close()
-def test_filtered_scoring_preserves_global_bm25_scores():
+@pytest.mark.asyncio
+async def test_filtered_scoring_preserves_global_bm25_scores():
"""Selected-document scoring filters results without redefining the corpus."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "high": "python python python",
+ "mid": "python python other",
+ "low": "python alpha beta",
+ "unrelated": "java only",
+ },
+ )
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "high": "python python python",
- "mid": "python python other",
- "low": "python alpha beta",
- "unrelated": "java only",
- },
- )
+ global_scores = await bm25.retrieve("python", limit=4)
+ selected = await bm25.score_documents("python", {"mid", "low", "unrelated", "missing"})
+ filtered = await bm25.retrieve_filtered("python", 1, {"mid", "low", "unrelated"})
- global_scores = await bm25.retrieve("python", limit=4)
- selected = await bm25.score_documents("python", {"mid", "low", "unrelated", "missing"})
- filtered = await bm25.retrieve_filtered("python", 1, {"mid", "low", "unrelated"})
-
- assert selected == {"mid": global_scores["mid"], "low": global_scores["low"]}
- assert filtered == {"mid": global_scores["mid"]}
- await bm25.close()
-
- run(go())
+ assert selected == {"mid": global_scores["mid"], "low": global_scores["low"]}
+ assert filtered == {"mid": global_scores["mid"]}
+ await bm25.close()
-def test_retrieve_idf_favours_rare_terms():
+@pytest.mark.asyncio
+async def test_retrieve_idf_favours_rare_terms():
"""In a query of {common, rare}, the doc containing the rare term wins."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ # 'common' appears everywhere → low IDF.
+ # 'rare' appears in only one doc → high IDF.
+ docs = {f"d{i}": "common filler text" for i in range(10)}
+ docs["target"] = "common rare term"
+ await bm25.add_docs(docs)
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- # 'common' appears everywhere → low IDF.
- # 'rare' appears in only one doc → high IDF.
- docs = {f"d{i}": "common filler text" for i in range(10)}
- docs["target"] = "common rare term"
- await bm25.add_docs(docs)
-
- results = await bm25.retrieve("common rare", limit=3)
- assert next(iter(results)) == "target"
- await bm25.close()
-
- run(go())
+ results = await bm25.retrieve("common rare", limit=3)
+ assert next(iter(results)) == "target"
+ await bm25.close()
-def test_retrieve_length_normalization():
+@pytest.mark.asyncio
+async def test_retrieve_length_normalization():
"""With b=0.75 (default), a much longer doc with same tf scores lower."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "short": "python",
- "long": "python " + " ".join(f"w{i}" for i in range(50)),
- },
- )
- results = await bm25.retrieve("python", limit=2)
- assert results["short"] > results["long"]
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "short": "python",
+ "long": "python " + " ".join(f"w{i}" for i in range(50)),
+ },
+ )
+ results = await bm25.retrieve("python", limit=2)
+ assert results["short"] > results["long"]
+ await bm25.close()
-def test_retrieve_duplicate_query_tokens_dont_double_count():
+@pytest.mark.asyncio
+async def test_retrieve_duplicate_query_tokens_dont_double_count():
"""Repeating the same query token should not boost its contribution."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "python rocks"})
- once = await bm25.retrieve("python", limit=1)
- many = await bm25.retrieve("python python python", limit=1)
- assert once["d1"] == many["d1"]
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "python rocks"})
+ once = await bm25.retrieve("python", limit=1)
+ many = await bm25.retrieve("python python python", limit=1)
+ assert once["d1"] == many["d1"]
+ await bm25.close()
# --------------------------------------------------------------------------- #
@@ -435,228 +357,198 @@ def test_retrieve_duplicate_query_tokens_dont_double_count():
# --------------------------------------------------------------------------- #
-def test_chinese_only_corpus():
+@pytest.mark.asyncio
+async def test_chinese_only_corpus():
"""Pure Chinese corpus indexes per-character and retrieves correctly."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "我爱北京天安门",
+ "d2": "北京是中国的首都",
+ "d3": "上海的天气很好",
+ },
+ )
+ # Regex tokenizer splits Chinese per character.
+ assert "北" in bm25.vocab
+ assert "京" in bm25.vocab
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "我爱北京天安门",
- "d2": "北京是中国的首都",
- "d3": "上海的天气很好",
- },
- )
- # Regex tokenizer splits Chinese per character.
- assert "北" in bm25.vocab
- assert "京" in bm25.vocab
-
- # Query "北京" → two tokens, both d1 and d2 match; d3 does not.
- results = await bm25.retrieve("北京", limit=3)
- assert set(results) == {"d1", "d2"}
- await bm25.close()
-
- run(go())
+ # Query "北京" → two tokens, both d1 and d2 match; d3 does not.
+ results = await bm25.retrieve("北京", limit=3)
+ assert set(results) == {"d1", "d2"}
+ await bm25.close()
-def test_english_only_corpus_is_lowercased():
+@pytest.mark.asyncio
+async def test_english_only_corpus_is_lowercased():
"""English tokens are lowercased so case-insensitive retrieval works."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "Python Programming Language",
+ "d2": "Java Programming Language",
+ },
+ )
+ assert "python" in bm25.vocab
+ assert "Python" not in bm25.vocab
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "Python Programming Language",
- "d2": "Java Programming Language",
- },
- )
- assert "python" in bm25.vocab
- assert "Python" not in bm25.vocab
-
- r_upper = await bm25.retrieve("PYTHON", limit=2)
- r_lower = await bm25.retrieve("python", limit=2)
- assert r_upper == r_lower
- assert "d1" in r_upper
- await bm25.close()
-
- run(go())
+ r_upper = await bm25.retrieve("PYTHON", limit=2)
+ r_lower = await bm25.retrieve("python", limit=2)
+ assert r_upper == r_lower
+ assert "d1" in r_upper
+ await bm25.close()
-def test_single_char_english_dropped():
+@pytest.mark.asyncio
+async def test_single_char_english_dropped():
"""RegexTokenizer's \\w\\w+ pattern drops single-letter ASCII tokens like 'I'."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "I love Beijing"})
- # 'I' must not appear; 'love' and 'beijing' must.
- assert "i" not in bm25.vocab
- assert "love" in bm25.vocab
- assert "beijing" in bm25.vocab
- # Querying with just "I" returns nothing.
- assert await bm25.retrieve("I", limit=1) == {}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "I love Beijing"})
+ # 'I' must not appear; 'love' and 'beijing' must.
+ assert "i" not in bm25.vocab
+ assert "love" in bm25.vocab
+ assert "beijing" in bm25.vocab
+ # Querying with just "I" returns nothing.
+ assert await bm25.retrieve("I", limit=1) == {}
+ await bm25.close()
-def test_mixed_doc_chinese_query_matches():
+@pytest.mark.asyncio
+async def test_mixed_doc_chinese_query_matches():
"""A Chinese query hits docs containing those Chinese chars even when mixed."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "Python 是一种编程语言",
- "d2": "Java 编程语言",
- "d3": "Python 数据分析",
- },
- )
- results = await bm25.retrieve("编程", limit=3)
- assert set(results) >= {"d1", "d2"}
- assert "d3" not in results
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "Python 是一种编程语言",
+ "d2": "Java 编程语言",
+ "d3": "Python 数据分析",
+ },
+ )
+ results = await bm25.retrieve("编程", limit=3)
+ assert set(results) >= {"d1", "d2"}
+ assert "d3" not in results
+ await bm25.close()
-def test_mixed_doc_english_query_matches():
+@pytest.mark.asyncio
+async def test_mixed_doc_english_query_matches():
"""An English query hits the right mixed-language docs."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "Python 是一种编程语言",
- "d2": "Java 编程语言",
- "d3": "Python 数据分析",
- },
- )
- results = await bm25.retrieve("python", limit=3)
- assert set(results) == {"d1", "d3"}
- assert "d2" not in results
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "Python 是一种编程语言",
+ "d2": "Java 编程语言",
+ "d3": "Python 数据分析",
+ },
+ )
+ results = await bm25.retrieve("python", limit=3)
+ assert set(results) == {"d1", "d3"}
+ assert "d2" not in results
+ await bm25.close()
-def test_mixed_query_combines_chinese_and_english_signal():
+@pytest.mark.asyncio
+async def test_mixed_query_combines_chinese_and_english_signal():
"""A query mixing English and Chinese aggregates IDF×tf contributions."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "py_cn": "Python 编程", # matches both 'python' and '编','程'
- "py_only": "Python tutorial", # matches only 'python'
- "cn_only": "编程入门", # matches only '编','程'
- # Avoid Chinese chars that the query splits into ('编','程') — '教程' would
- # leak '程' into 'other' and pollute IDF, so use unrelated chars only.
- "other": "Java 教学",
- },
- )
- results = await bm25.retrieve("Python 编程", limit=4)
- # py_cn should rank highest because it matches both branches.
- assert next(iter(results)) == "py_cn"
- # 'other' should not appear.
- assert "other" not in results
- # Both unimodal matches should still appear.
- assert "py_only" in results and "cn_only" in results
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "py_cn": "Python 编程", # matches both 'python' and '编','程'
+ "py_only": "Python tutorial", # matches only 'python'
+ "cn_only": "编程入门", # matches only '编','程'
+ # Avoid Chinese chars that the query splits into ('编','程') — '教程' would
+ # leak '程' into 'other' and pollute IDF, so use unrelated chars only.
+ "other": "Java 教学",
+ },
+ )
+ results = await bm25.retrieve("Python 编程", limit=4)
+ # py_cn should rank highest because it matches both branches.
+ assert next(iter(results)) == "py_cn"
+ # 'other' should not appear.
+ assert "other" not in results
+ # Both unimodal matches should still appear.
+ assert "py_only" in results and "cn_only" in results
+ await bm25.close()
-def test_mixed_doc_more_matches_outrank_fewer():
+@pytest.mark.asyncio
+async def test_mixed_doc_more_matches_outrank_fewer():
"""Doc covering more query tokens (Chinese+English) outranks partial matches."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "full": "machine learning 机器 学习",
- "en_only": "machine learning algorithm",
- "cn_only": "机器 学习 算法",
- },
- )
- results = await bm25.retrieve("machine 机器", limit=3)
- # full has both English and Chinese hits → highest score.
- assert next(iter(results)) == "full"
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "full": "machine learning 机器 学习",
+ "en_only": "machine learning algorithm",
+ "cn_only": "机器 学习 算法",
+ },
+ )
+ results = await bm25.retrieve("machine 机器", limit=3)
+ # full has both English and Chinese hits → highest score.
+ assert next(iter(results)) == "full"
+ await bm25.close()
-def test_unicode_word_with_digits_preserved():
+@pytest.mark.asyncio
+async def test_unicode_word_with_digits_preserved():
"""Alphanumeric tokens like 'iphone15' stay whole; trailing Chinese still split."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "iPhone15 Pro 售价 9999 元",
+ "d2": "Android 旗舰 999 元",
+ },
+ )
+ assert "iphone15" in bm25.vocab
+ assert "9999" in bm25.vocab
+ assert "元" in bm25.vocab
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "iPhone15 Pro 售价 9999 元",
- "d2": "Android 旗舰 999 元",
- },
- )
- assert "iphone15" in bm25.vocab
- assert "9999" in bm25.vocab
- assert "元" in bm25.vocab
-
- r1 = await bm25.retrieve("iphone15", limit=2)
- assert list(r1) == ["d1"]
- r2 = await bm25.retrieve("元", limit=2)
- assert set(r2) == {"d1", "d2"}
- await bm25.close()
-
- run(go())
+ r1 = await bm25.retrieve("iphone15", limit=2)
+ assert list(r1) == ["d1"]
+ r2 = await bm25.retrieve("元", limit=2)
+ assert set(r2) == {"d1", "d2"}
+ await bm25.close()
-def test_chinese_punctuation_ignored():
+@pytest.mark.asyncio
+async def test_chinese_punctuation_ignored():
"""CJK punctuation should not produce tokens."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "你好,世界!这是 Python。"})
- for sym in [",", "!", "。"]:
- assert sym not in bm25.vocab
- assert "你" in bm25.vocab
- assert "python" in bm25.vocab
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "你好,世界!这是 Python。"})
+ for sym in [",", "!", "。"]:
+ assert sym not in bm25.vocab
+ assert "你" in bm25.vocab
+ assert "python" in bm25.vocab
+ await bm25.close()
-def test_mixed_persistence_roundtrip():
+@pytest.mark.asyncio
+async def test_mixed_persistence_roundtrip():
"""A mixed-language index round-trips through dump/load with identical retrieval."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "Python 编程语言",
+ "d2": "Java 编程",
+ "d3": "数据分析 with Python",
+ },
+ )
+ before = await bm25.retrieve("Python 编程", limit=3)
+ await bm25.close() # close triggers dump
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "Python 编程语言",
- "d2": "Java 编程",
- "d3": "数据分析 with Python",
- },
- )
- before = await bm25.retrieve("Python 编程", limit=3)
- await bm25.close() # close triggers dump
-
- bm25_2 = await create_bm25() # start triggers load
- assert bm25_2.n_docs == 3
- after = await bm25_2.retrieve("Python 编程", limit=3)
- assert before == after
- await bm25_2.close()
-
- run(go())
+ bm25_2 = await create_bm25() # start triggers load
+ assert bm25_2.n_docs == 3
+ after = await bm25_2.retrieve("Python 编程", limit=3)
+ assert before == after
+ await bm25_2.close()
# --------------------------------------------------------------------------- #
@@ -664,248 +556,221 @@ def test_mixed_persistence_roundtrip():
# --------------------------------------------------------------------------- #
-def test_dump_load_roundtrip_preserves_state():
+@pytest.mark.asyncio
+async def test_dump_load_roundtrip_preserves_state():
"""dump → fresh instance → load reconstructs vocab, postings and params."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25(k1=2.0, b=0.4)
+ await bm25.add_docs(
+ {
+ "d1": "hello world",
+ "d2": "hello python",
+ "d3": "programming language",
+ },
+ )
+ old_vocab = dict(bm25.vocab)
+ old_meta = {k: dict(v) for k, v in bm25.doc_meta.items()}
+ await bm25.dump()
+ await bm25.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25(k1=2.0, b=0.4)
- await bm25.add_docs(
- {
- "d1": "hello world",
- "d2": "hello python",
- "d3": "programming language",
- },
- )
- old_vocab = dict(bm25.vocab)
- old_meta = {k: dict(v) for k, v in bm25.doc_meta.items()}
- await bm25.dump()
- await bm25.close()
-
- bm25_2 = await create_bm25() # default k1/b — load must overwrite
- assert bm25_2.vocab == old_vocab
- assert bm25_2.n_docs == 3
- assert set(bm25_2.doc_meta) == set(old_meta)
- assert bm25_2.k1 == 2.0
- assert bm25_2.b == 0.4
- await bm25_2.close()
-
- run(go())
+ bm25_2 = await create_bm25() # default k1/b — load must overwrite
+ assert bm25_2.vocab == old_vocab
+ assert bm25_2.n_docs == 3
+ assert set(bm25_2.doc_meta) == set(old_meta)
+ assert bm25_2.k1 == 2.0
+ assert bm25_2.b == 0.4
+ await bm25_2.close()
-def test_runtime_parameter_update_survives_restart():
+@pytest.mark.asyncio
+async def test_runtime_parameter_update_survives_restart():
"""Persist k1/b updates made after loading an existing index."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await create_bm25()
+ await seed.add_docs({"d1": "hello world"})
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await create_bm25()
- await seed.add_docs({"d1": "hello world"})
- await seed.close()
+ changed = await create_bm25()
+ assert (changed.k1, changed.b) == (1.5, 0.75)
+ changed.k1 = 2.0
+ changed.b = 0.4
+ await changed.close()
- changed = await create_bm25()
- assert (changed.k1, changed.b) == (1.5, 0.75)
- changed.k1 = 2.0
- changed.b = 0.4
- await changed.close()
-
- reopened = await create_bm25()
- assert (reopened.k1, reopened.b) == (2.0, 0.4)
- await reopened.close()
-
- run(go())
+ reopened = await create_bm25()
+ assert (reopened.k1, reopened.b) == (2.0, 0.4)
+ await reopened.close()
-def test_load_missing_file_keeps_empty_state():
+@pytest.mark.asyncio
+async def test_load_missing_file_keeps_empty_state():
"""Calling load() with no file on disk is a no-op."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- # No add_docs, nothing persisted.
- assert not bm25.index_file.exists()
- await bm25.load()
- assert bm25.n_docs == 0
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ # No add_docs, nothing persisted.
+ assert not bm25.index_file.exists()
+ await bm25.load()
+ assert bm25.n_docs == 0
+ await bm25.close()
-def test_load_corrupt_file_resets_index():
+@pytest.mark.asyncio
+async def test_load_corrupt_file_resets_index():
"""A corrupt pickle on disk is reported, deleted, and the index is cleared."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello"})
+ await bm25.dump()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello"})
- await bm25.dump()
-
- # Corrupt the file.
- bm25.index_file.write_bytes(b"not a pickle")
- await bm25.load()
- assert bm25.n_docs == 0
- assert bm25.vocab == {}
- assert not bm25.index_file.exists()
- await bm25.close()
-
- run(go())
+ # Corrupt the file.
+ bm25.index_file.write_bytes(b"not a pickle")
+ await bm25.load()
+ assert bm25.n_docs == 0
+ assert bm25.vocab == {}
+ assert not bm25.index_file.exists()
+ await bm25.close()
-def test_index_file_isolated_by_component_name():
+@pytest.mark.asyncio
+async def test_index_file_isolated_by_component_name():
"""Different BM25Index names must not share one persisted pickle."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ alpha = BM25Index(name="alpha")
+ alpha_tokenizer = RegexTokenizer(filter_stopwords=False)
+ alpha.tokenizer = alpha_tokenizer
+ alpha._owned.append(alpha_tokenizer)
+ await alpha.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- alpha = BM25Index(name="alpha")
- alpha_tokenizer = RegexTokenizer(filter_stopwords=False)
- alpha.tokenizer = alpha_tokenizer
- alpha._owned.append(alpha_tokenizer)
- await alpha.start()
+ beta = BM25Index(name="beta")
+ beta_tokenizer = RegexTokenizer(filter_stopwords=False)
+ beta.tokenizer = beta_tokenizer
+ beta._owned.append(beta_tokenizer)
+ await beta.start()
- beta = BM25Index(name="beta")
- beta_tokenizer = RegexTokenizer(filter_stopwords=False)
- beta.tokenizer = beta_tokenizer
- beta._owned.append(beta_tokenizer)
- await beta.start()
+ assert alpha.index_file != beta.index_file
- assert alpha.index_file != beta.index_file
+ await alpha.add_docs({"d1": "alpha only"})
+ await alpha.dump()
+ await alpha.close()
- await alpha.add_docs({"d1": "alpha only"})
- await alpha.dump()
- await alpha.close()
-
- assert beta.n_docs == 0
- assert await beta.retrieve("alpha", limit=1) == {}
- await beta.close()
-
- run(go())
+ assert beta.n_docs == 0
+ assert await beta.retrieve("alpha", limit=1) == {}
+ await beta.close()
-def test_index_file_isolated_by_tokenizer_config():
+@pytest.mark.asyncio
+async def test_index_file_isolated_by_tokenizer_config():
"""Tokenizer settings that affect tokens must map to different index files."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ unfiltered = BM25Index()
+ unfiltered_tokenizer = RegexTokenizer(filter_stopwords=False)
+ unfiltered.tokenizer = unfiltered_tokenizer
+ unfiltered._owned.append(unfiltered_tokenizer)
+ await unfiltered.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- unfiltered = BM25Index()
- unfiltered_tokenizer = RegexTokenizer(filter_stopwords=False)
- unfiltered.tokenizer = unfiltered_tokenizer
- unfiltered._owned.append(unfiltered_tokenizer)
- await unfiltered.start()
+ filtered = BM25Index()
+ filtered_tokenizer = RegexTokenizer(filter_stopwords=True)
+ filtered.tokenizer = filtered_tokenizer
+ filtered._owned.append(filtered_tokenizer)
+ await filtered.start()
- filtered = BM25Index()
- filtered_tokenizer = RegexTokenizer(filter_stopwords=True)
- filtered.tokenizer = filtered_tokenizer
- filtered._owned.append(filtered_tokenizer)
- await filtered.start()
+ assert unfiltered.index_file != filtered.index_file
- assert unfiltered.index_file != filtered.index_file
-
- await unfiltered.close()
- await filtered.close()
-
- run(go())
+ await unfiltered.close()
+ await filtered.close()
-def test_tokenizer_fingerprint_ignores_stopwords_absolute_path():
+@pytest.mark.asyncio
+async def test_tokenizer_fingerprint_ignores_stopwords_absolute_path():
"""Same stopwords content should not fork indexes by install path."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ stopwords_a = os.path.join(tmp, "a", "stopwords")
+ stopwords_b = os.path.join(tmp, "b", "stopwords")
+ os.makedirs(os.path.dirname(stopwords_a))
+ os.makedirs(os.path.dirname(stopwords_b))
+ with open(stopwords_a, "w", encoding="utf-8") as f:
+ f.write("alpha\nbeta\n")
+ with open(stopwords_b, "w", encoding="utf-8") as f:
+ f.write("alpha\nbeta\n")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- stopwords_a = os.path.join(tmp, "a", "stopwords")
- stopwords_b = os.path.join(tmp, "b", "stopwords")
- os.makedirs(os.path.dirname(stopwords_a))
- os.makedirs(os.path.dirname(stopwords_b))
- with open(stopwords_a, "w", encoding="utf-8") as f:
- f.write("alpha\nbeta\n")
- with open(stopwords_b, "w", encoding="utf-8") as f:
- f.write("alpha\nbeta\n")
+ first = BM25Index()
+ first_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_a)
+ first.tokenizer = first_tokenizer
+ first._owned.append(first_tokenizer)
+ await first.start()
- first = BM25Index()
- first_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_a)
- first.tokenizer = first_tokenizer
- first._owned.append(first_tokenizer)
- await first.start()
+ second = BM25Index()
+ second_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_b)
+ second.tokenizer = second_tokenizer
+ second._owned.append(second_tokenizer)
+ await second.start()
- second = BM25Index()
- second_tokenizer = RegexTokenizer(filter_stopwords=True, stopwords_path=stopwords_b)
- second.tokenizer = second_tokenizer
- second._owned.append(second_tokenizer)
- await second.start()
+ assert first._tokenizer_config()["stopwords_sha256"] == second._tokenizer_config()["stopwords_sha256"]
+ assert "stopwords_path" not in first._tokenizer_config()
+ assert first._tokenizer_fingerprint() == second._tokenizer_fingerprint()
+ assert first.index_file == second.index_file
- assert first._tokenizer_config()["stopwords_sha256"] == second._tokenizer_config()["stopwords_sha256"]
- assert "stopwords_path" not in first._tokenizer_config()
- assert first._tokenizer_fingerprint() == second._tokenizer_fingerprint()
- assert first.index_file == second.index_file
+ with open(stopwords_b, "w", encoding="utf-8") as f:
+ f.write("alpha\ngamma\n")
+ assert first._tokenizer_fingerprint() != second._tokenizer_fingerprint()
- with open(stopwords_b, "w", encoding="utf-8") as f:
- f.write("alpha\ngamma\n")
- assert first._tokenizer_fingerprint() != second._tokenizer_fingerprint()
-
- await first.close()
- await second.close()
-
- run(go())
+ await first.close()
+ await second.close()
-def test_dump_failure_is_not_silent():
+@pytest.mark.asyncio
+async def test_dump_failure_is_not_silent():
"""A failed write must be observable by callers."""
+ from unittest.mock import patch
- async def go():
- from unittest.mock import patch
-
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello"})
- with patch("builtins.open", side_effect=OSError("disk full")):
- try:
- await bm25.dump()
- except OSError:
- pass
- else:
- raise AssertionError("expected dump() to raise OSError")
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello"})
+ with patch("builtins.open", side_effect=OSError("disk full")):
+ try:
+ await bm25.dump()
+ except OSError:
+ pass
+ else:
+ raise AssertionError("expected dump() to raise OSError")
+ await bm25.close()
-def test_concurrent_dumps_publish_in_invocation_order():
+@pytest.mark.asyncio
+async def test_concurrent_dumps_publish_in_invocation_order():
"""A newer dump must wait for and then supersede an older snapshot."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "alpha"})
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "alpha"})
+ first_started = threading.Event()
+ release_first = threading.Event()
+ dump_calls = 0
+ original_dump_sync = bm25._dump_sync
- first_started = threading.Event()
- release_first = threading.Event()
- dump_calls = 0
- original_dump_sync = bm25._dump_sync
+ def blocking_dump_sync(snapshot):
+ nonlocal dump_calls
+ dump_calls += 1
+ if dump_calls == 1:
+ first_started.set()
+ release_first.wait()
+ original_dump_sync(snapshot)
- def blocking_dump_sync(snapshot):
- nonlocal dump_calls
- dump_calls += 1
- if dump_calls == 1:
- first_started.set()
- release_first.wait()
- original_dump_sync(snapshot)
+ bm25._dump_sync = blocking_dump_sync
+ first = asyncio.create_task(bm25.dump())
+ assert await asyncio.to_thread(first_started.wait, 1)
- bm25._dump_sync = blocking_dump_sync
- first = asyncio.create_task(bm25.dump())
- assert await asyncio.to_thread(first_started.wait, 1)
+ await bm25.add_docs({"d2": "beta"})
+ second = asyncio.create_task(bm25.dump())
+ await asyncio.sleep(0.02)
+ assert dump_calls == 1
- await bm25.add_docs({"d2": "beta"})
- second = asyncio.create_task(bm25.dump())
- await asyncio.sleep(0.02)
- assert dump_calls == 1
+ release_first.set()
+ await asyncio.gather(first, second)
+ assert dump_calls == 2
+ assert set(bm25._load_sync()["doc_id_to_idx"]) == {"d1", "d2"}
- release_first.set()
- await asyncio.gather(first, second)
- assert dump_calls == 2
- assert set(bm25._load_sync()["doc_id_to_idx"]) == {"d1", "d2"}
-
- bm25._dump_sync = original_dump_sync
- await bm25.close()
-
- run(go())
+ bm25._dump_sync = original_dump_sync
+ await bm25.close()
# --------------------------------------------------------------------------- #
@@ -913,107 +778,92 @@ def test_concurrent_dumps_publish_in_invocation_order():
# --------------------------------------------------------------------------- #
-def test_clear_wipes_everything():
+@pytest.mark.asyncio
+async def test_clear_wipes_everything():
"""clear() empties state and removes the index file."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello", "d2": "world"})
+ await bm25.dump()
+ assert bm25.index_file.exists()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello", "d2": "world"})
- await bm25.dump()
- assert bm25.index_file.exists()
-
- await bm25.clear()
- assert bm25.n_docs == 0
- assert bm25.vocab == {}
- assert bm25.inverted_index == {}
- assert bm25.doc_meta == {}
- assert bm25.total_len == 0
- assert bm25._idf_cache == {}
- assert not bm25.index_file.exists()
- await bm25.close()
- assert not bm25.index_file.exists()
-
- run(go())
+ await bm25.clear()
+ assert bm25.n_docs == 0
+ assert bm25.vocab == {}
+ assert bm25.inverted_index == {}
+ assert bm25.doc_meta == {}
+ assert bm25.total_len == 0
+ assert bm25._idf_cache == {}
+ assert not bm25.index_file.exists()
+ await bm25.close()
+ assert not bm25.index_file.exists()
-def test_optimize_drops_deleted_only_terms():
+@pytest.mark.asyncio
+async def test_optimize_drops_deleted_only_terms():
"""After deleting, optimize_index drops vocab entries that no live doc uses."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs(
+ {
+ "d1": "alpha unique_to_d1",
+ "d2": "alpha beta",
+ },
+ )
+ assert "unique_to_d1" in bm25.vocab
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs(
- {
- "d1": "alpha unique_to_d1",
- "d2": "alpha beta",
- },
- )
- assert "unique_to_d1" in bm25.vocab
+ await bm25.delete_docs(["d1"])
+ await bm25.optimize_index()
- await bm25.delete_docs(["d1"])
- await bm25.optimize_index()
-
- assert bm25.n_docs == 1
- assert "d2" in bm25.doc_meta
- # Term that only existed in d1 is gone.
- assert "unique_to_d1" not in bm25.vocab
- # Shared/own terms of d2 survive.
- assert "alpha" in bm25.vocab and "beta" in bm25.vocab
- # Retrieval still works correctly.
- assert "d2" in await bm25.retrieve("alpha", limit=1)
- await bm25.close()
-
- run(go())
+ assert bm25.n_docs == 1
+ assert "d2" in bm25.doc_meta
+ # Term that only existed in d1 is gone.
+ assert "unique_to_d1" not in bm25.vocab
+ # Shared/own terms of d2 survive.
+ assert "alpha" in bm25.vocab and "beta" in bm25.vocab
+ # Retrieval still works correctly.
+ assert "d2" in await bm25.retrieve("alpha", limit=1)
+ await bm25.close()
-def test_optimize_when_all_deleted_clears_index():
+@pytest.mark.asyncio
+async def test_optimize_when_all_deleted_clears_index():
"""optimize_index on a fully-deleted state collapses to empty index."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world"})
- await bm25.delete_docs(["d1"])
- await bm25.optimize_index()
- assert bm25.n_docs == 0
- assert bm25.vocab == {}
- assert bm25.inverted_index == {}
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world"})
+ await bm25.delete_docs(["d1"])
+ await bm25.optimize_index()
+ assert bm25.n_docs == 0
+ assert bm25.vocab == {}
+ assert bm25.inverted_index == {}
+ await bm25.close()
-def test_optimize_noop_when_no_deletions():
+@pytest.mark.asyncio
+async def test_optimize_noop_when_no_deletions():
"""With nothing deleted, optimize_index leaves state intact."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world"})
- vocab_before = dict(bm25.vocab)
- await bm25.optimize_index()
- assert bm25.vocab == vocab_before
- assert bm25.n_docs == 1
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world"})
+ vocab_before = dict(bm25.vocab)
+ await bm25.optimize_index()
+ assert bm25.vocab == vocab_before
+ assert bm25.n_docs == 1
+ await bm25.close()
-def test_reset_index_replaces_all_docs():
+@pytest.mark.asyncio
+async def test_reset_index_replaces_all_docs():
"""reset_index (inherited from base) wipes and re-adds in one call."""
-
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "old content"})
- await bm25.reset_index({"d2": "new content"})
- assert bm25.n_docs == 1
- assert "d2" in bm25.doc_meta
- assert "d1" not in bm25.doc_meta
- await bm25.close()
-
- run(go())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "old content"})
+ await bm25.reset_index({"d2": "new content"})
+ assert bm25.n_docs == 1
+ assert "d2" in bm25.doc_meta
+ assert "d1" not in bm25.doc_meta
+ await bm25.close()
# --------------------------------------------------------------------------- #
@@ -1021,115 +871,74 @@ def test_reset_index_replaces_all_docs():
# --------------------------------------------------------------------------- #
-def test_idf_cache_populates_and_invalidates():
+@pytest.mark.asyncio
+async def test_idf_cache_populates_and_invalidates():
"""_get_idf caches results; add/delete clear the cache."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "hello world", "d2": "hello python"})
+ tid_hello = bm25.vocab["hello"]
+ idf1 = bm25._get_idf(tid_hello)
+ assert tid_hello in bm25._idf_cache
+ assert bm25._get_idf(tid_hello) == idf1
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "hello world", "d2": "hello python"})
- tid_hello = bm25.vocab["hello"]
- idf1 = bm25._get_idf(tid_hello)
- assert tid_hello in bm25._idf_cache
- assert bm25._get_idf(tid_hello) == idf1
+ # Mutating the index must invalidate the cache.
+ await bm25.add_docs({"d3": "hello there"})
+ assert bm25._idf_cache == {}
- # Mutating the index must invalidate the cache.
- await bm25.add_docs({"d3": "hello there"})
- assert bm25._idf_cache == {}
-
- await bm25.delete_docs(["d1"])
- # delete_docs also clears cache; populate again then trigger via add.
- _ = bm25._get_idf(bm25.vocab["hello"])
- assert bm25._idf_cache # non-empty now
- await bm25.add_docs({"d4": "x y z"})
- assert bm25._idf_cache == {}
- await bm25.close()
-
- run(go())
+ await bm25.delete_docs(["d1"])
+ # delete_docs also clears cache; populate again then trigger via add.
+ _ = bm25._get_idf(bm25.vocab["hello"])
+ assert bm25._idf_cache # non-empty now
+ await bm25.add_docs({"d4": "x y z"})
+ assert bm25._idf_cache == {}
+ await bm25.close()
-def test_avg_len_tracks_live_docs_only():
+@pytest.mark.asyncio
+async def test_avg_len_tracks_live_docs_only():
"""avg_len excludes deleted docs.
Note: RegexTokenizer's `\\w\\w+` pattern drops 1-letter words, so we
use multi-letter tokens to keep length math predictable.
"""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ assert bm25.avg_len == 0.0
+ await bm25.add_docs({"d1": "alpha beta gamma delta"}) # 4 tokens
+ await bm25.add_docs({"d2": "alpha beta"}) # 2 tokens
+ assert bm25.avg_len == 3.0
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- assert bm25.avg_len == 0.0
- await bm25.add_docs({"d1": "alpha beta gamma delta"}) # 4 tokens
- await bm25.add_docs({"d2": "alpha beta"}) # 2 tokens
- assert bm25.avg_len == 3.0
-
- await bm25.delete_docs(["d1"])
- assert bm25.avg_len == 2.0
- await bm25.close()
-
- run(go())
+ await bm25.delete_docs(["d1"])
+ assert bm25.avg_len == 2.0
+ await bm25.close()
-def test_deleted_docs_excluded_from_scoring():
+@pytest.mark.asyncio
+async def test_deleted_docs_excluded_from_scoring():
"""A deleted doc must score 0 and never appear in retrieve()."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "python", "d2": "python", "d3": "python"})
+ await bm25.delete_docs(["d2"])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "python", "d2": "python", "d3": "python"})
- await bm25.delete_docs(["d2"])
-
- results = await bm25.retrieve("python", limit=10)
- assert set(results) == {"d1", "d3"}
- assert all(s > 0 for s in results.values())
- await bm25.close()
-
- run(go())
+ results = await bm25.retrieve("python", limit=10)
+ assert set(results) == {"d1", "d3"}
+ assert all(s > 0 for s in results.values())
+ await bm25.close()
-def test_inverted_index_hides_deleted_postings():
+@pytest.mark.asyncio
+async def test_inverted_index_hides_deleted_postings():
"""inverted_index view skips postings whose doc is deleted."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ bm25 = await create_bm25()
+ await bm25.add_docs({"d1": "alpha", "d2": "alpha beta"})
+ tid_alpha = bm25.vocab["alpha"]
+ await bm25.delete_docs(["d1"])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- bm25 = await create_bm25()
- await bm25.add_docs({"d1": "alpha", "d2": "alpha beta"})
- tid_alpha = bm25.vocab["alpha"]
- await bm25.delete_docs(["d1"])
-
- inv = bm25.inverted_index
- # 'alpha' posting now contains only the live doc.
- assert tid_alpha in inv
- assert set(inv[tid_alpha]) == {"d2"}
- await bm25.close()
-
- run(go())
-
-
-# --------------------------------------------------------------------------- #
-# Manual runner #
-# --------------------------------------------------------------------------- #
-
-
-if __name__ == "__main__":
- import inspect
- import sys
-
- mod = sys.modules[__name__]
- tests = [(name, obj) for name, obj in inspect.getmembers(mod, inspect.isfunction) if name.startswith("test_")]
- print(f"\n=== BaseKeywordIndex / BM25Index Tests ({len(tests)}) ===\n")
- failed = []
- for name, fn in tests:
- try:
- fn()
- print(f"✓ {name}")
- except Exception as exc: # noqa: BLE001
- print(f"✗ {name}: {exc!r}")
- failed.append(name)
- print()
- if failed:
- print(f"FAILED: {len(failed)} / {len(tests)}")
- for n in failed:
- print(f" - {n}")
- sys.exit(1)
- print(f"所有 {len(tests)} 项测试通过!")
+ inv = bm25.inverted_index
+ # 'alpha' posting now contains only the live doc.
+ assert tid_alpha in inv
+ assert set(inv[tid_alpha]) == {"d2"}
+ await bm25.close()
diff --git a/tests/unit/test_link_expansion.py b/tests/unit/test_link_expansion.py
index 57de6aec..61034896 100644
--- a/tests/unit/test_link_expansion.py
+++ b/tests/unit/test_link_expansion.py
@@ -11,12 +11,13 @@ Two pure helpers:
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
import warnings
from pathlib import Path
+import pytest
+
from reme.components.file_store import LocalFileStore
from reme.schema import FileFrontMatter, FileNode
from reme.utils.link_expansion import expand_links, render_expansion_lines
@@ -26,22 +27,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Test helper: chdir to ``path`` on enter, restore previous cwd on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
async def _store_with(files: dict[str, dict]) -> LocalFileStore:
"""LocalFileStore seeded with files + parsed wikilinks + optional frontmatter meta.
@@ -79,98 +64,86 @@ async def _store_with(files: dict[str, dict]) -> LocalFileStore:
# -- expand_links -------------------------------------------------------------
-def test_expand_links_empty_paths_short_circuits():
+@pytest.mark.asyncio
+async def test_expand_links_empty_paths_short_circuits():
"""No paths ⇒ empty dict, no file_store calls needed."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = LocalFileStore(name="t", embedding_store="")
- await store.start()
- result = await expand_links(store, [])
- assert result == {}
- await store.close()
- print("✓ test_expand_links_empty_paths_short_circuits passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = LocalFileStore(name="t", embedding_store="")
+ await store.start()
+ result = await expand_links(store, [])
+ assert result == {}
+ await store.close()
-def test_expand_links_returns_outlinks_and_inlinks_with_meta():
+@pytest.mark.asyncio
+async def test_expand_links_returns_outlinks_and_inlinks_with_meta():
"""A.md links to B.md ⇒ A has B as outlink, B has A as inlink, meta surfaced."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc", "description": "alpha"},
- "B.md": {"body": "End node.", "name": "B Doc", "description": "beta"},
- },
- )
- result = await expand_links(store, ["A.md", "B.md"])
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc", "description": "alpha"},
+ "B.md": {"body": "End node.", "name": "B Doc", "description": "beta"},
+ },
+ )
+ result = await expand_links(store, ["A.md", "B.md"])
- assert set(result.keys()) == {"A.md", "B.md"}
+ assert set(result.keys()) == {"A.md", "B.md"}
- a_out = result["A.md"]["outlinks"]
- assert len(a_out) == 1
- assert a_out[0]["path"] == "B.md"
- assert a_out[0]["meta"] == {"name": "B Doc", "description": "beta"}
- assert a_out[0]["anchors"] == []
- assert result["A.md"]["inlinks"] == []
+ a_out = result["A.md"]["outlinks"]
+ assert len(a_out) == 1
+ assert a_out[0]["path"] == "B.md"
+ assert a_out[0]["meta"] == {"name": "B Doc", "description": "beta"}
+ assert a_out[0]["anchors"] == []
+ assert result["A.md"]["inlinks"] == []
- b_in = result["B.md"]["inlinks"]
- assert len(b_in) == 1
- assert b_in[0]["path"] == "A.md"
- assert b_in[0]["meta"] == {"name": "A Doc", "description": "alpha"}
- assert result["B.md"]["outlinks"] == []
+ b_in = result["B.md"]["inlinks"]
+ assert len(b_in) == 1
+ assert b_in[0]["path"] == "A.md"
+ assert b_in[0]["meta"] == {"name": "A Doc", "description": "alpha"}
+ assert result["B.md"]["outlinks"] == []
- await store.close()
- print("✓ test_expand_links_returns_outlinks_and_inlinks_with_meta passed")
-
- asyncio.run(run())
+ await store.close()
-def test_expand_links_max_per_direction_caps_neighbors():
+@pytest.mark.asyncio
+async def test_expand_links_max_per_direction_caps_neighbors():
"""max_per_direction=2 ⇒ only first two distinct neighbors per direction kept."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "hub.md": {
- "body": "[[a.md]] [[b.md]] [[c.md]] [[d.md]]",
- },
- "a.md": {"body": "a"},
- "b.md": {"body": "b"},
- "c.md": {"body": "c"},
- "d.md": {"body": "d"},
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "hub.md": {
+ "body": "[[a.md]] [[b.md]] [[c.md]] [[d.md]]",
},
- )
- result = await expand_links(store, ["hub.md"], max_per_direction=2)
- out = result["hub.md"]["outlinks"]
- assert len(out) == 2
- assert [n["path"] for n in out] == ["a.md", "b.md"]
- await store.close()
- print("✓ test_expand_links_max_per_direction_caps_neighbors passed")
-
- asyncio.run(run())
+ "a.md": {"body": "a"},
+ "b.md": {"body": "b"},
+ "c.md": {"body": "c"},
+ "d.md": {"body": "d"},
+ },
+ )
+ result = await expand_links(store, ["hub.md"], max_per_direction=2)
+ out = result["hub.md"]["outlinks"]
+ assert len(out) == 2
+ assert [n["path"] for n in out] == ["a.md", "b.md"]
+ await store.close()
-def test_expand_links_node_without_meta_returns_empty_meta_dict():
+@pytest.mark.asyncio
+async def test_expand_links_node_without_meta_returns_empty_meta_dict():
"""Neighbor with no frontmatter name/description ⇒ meta = {}."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "src.md": {"body": "[[dst.md]]"},
- "dst.md": {"body": "no meta"},
- },
- )
- result = await expand_links(store, ["src.md"])
- assert result["src.md"]["outlinks"][0]["meta"] == {}
- await store.close()
- print("✓ test_expand_links_node_without_meta_returns_empty_meta_dict passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "src.md": {"body": "[[dst.md]]"},
+ "dst.md": {"body": "no meta"},
+ },
+ )
+ result = await expand_links(store, ["src.md"])
+ assert result["src.md"]["outlinks"][0]["meta"] == {}
+ await store.close()
# -- render_expansion_lines ---------------------------------------------------
@@ -180,7 +153,6 @@ def test_render_expansion_lines_empty_input_yields_empty_list():
"""Both directions empty ⇒ no lines."""
assert not render_expansion_lines({})
assert not render_expansion_lines({"outlinks": [], "inlinks": []})
- print("✓ test_render_expansion_lines_empty_input_yields_empty_list passed")
def test_render_expansion_lines_outlinks_only():
@@ -200,7 +172,6 @@ def test_render_expansion_lines_outlinks_only():
" outlinks (1):",
' → B.md name="B" description="beta"',
]
- print("✓ test_render_expansion_lines_outlinks_only passed")
def test_render_expansion_lines_inlinks_only_with_anchor():
@@ -221,7 +192,6 @@ def test_render_expansion_lines_inlinks_only_with_anchor():
" ← src.md (no meta)",
" via anchor=#intro",
]
- print("✓ test_render_expansion_lines_inlinks_only_with_anchor passed")
def test_render_expansion_lines_both_directions_in_order():
@@ -239,15 +209,3 @@ def test_render_expansion_lines_both_directions_in_order():
assert lines[2] == " inlinks (1):"
assert lines[1].lstrip().startswith("→")
assert lines[3].lstrip().startswith("←")
- print("✓ test_render_expansion_lines_both_directions_in_order passed")
-
-
-if __name__ == "__main__":
- test_expand_links_empty_paths_short_circuits()
- test_expand_links_returns_outlinks_and_inlinks_with_meta()
- test_expand_links_max_per_direction_caps_neighbors()
- test_expand_links_node_without_meta_returns_empty_meta_dict()
- test_render_expansion_lines_empty_input_yields_empty_list()
- test_render_expansion_lines_outlinks_only()
- test_render_expansion_lines_inlinks_only_with_anchor()
- test_render_expansion_lines_both_directions_in_order()
diff --git a/tests/unit/test_local_embedding_store.py b/tests/unit/test_local_embedding_store.py
index 51b7bf63..ed9d705f 100644
--- a/tests/unit/test_local_embedding_store.py
+++ b/tests/unit/test_local_embedding_store.py
@@ -1,13 +1,18 @@
"""Regression tests for LocalEmbeddingStore dimension and vector space handling."""
-# pylint: disable=protected-access
+# pylint: disable=protected-access,too-many-lines
import asyncio
+import json
from types import SimpleNamespace
+import httpx
import numpy as np
+import pytest
+import openai
from reme.components.as_embedding import DashScopeAsEmbedding, OllamaAsEmbedding, OpenAIAsEmbedding
+from reme.components.embedding_store._embedding_types import _EmbeddingError
from reme.components.embedding_store.base_embedding_store import BaseEmbeddingStore
from reme.components.embedding_store.local_embedding_store import LocalEmbeddingStore
from reme.schema import EmbNode
@@ -126,6 +131,298 @@ class QuotaThenSuccessAsEmbedding:
return [[1.0, 0.0] for _ in texts]
+class ContextLengthError(Exception):
+ """OpenAI-compatible 400 raised when one item exceeds the provider's per-item window."""
+
+ status_code = 400
+ body = {"error": {"message": "the input length exceeds the context length"}}
+
+
+class PerItemLimitAsEmbedding:
+ """Reject a whole request when any single text is over the provider's per-item limit.
+
+ Mirrors an OpenAI-compatible gateway: one over-limit item makes the provider answer
+ HTTP 400 for the entire batch, so a client that gives up on the batch loses every
+ vector in it.
+ """
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+ max_chars = 32
+
+ def __init__(self):
+ self.batch_sizes: list[int] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ raise ContextLengthError("the input length exceeds the context length")
+ return [[1.0, 0.0] for _ in texts]
+
+
+class AlwaysInsufficientQuotaAsEmbedding:
+ """Always fail with quota exhaustion, recording every requested batch size."""
+
+ dimensions = 2
+
+ def __init__(self):
+ self.batch_sizes: list[int] = []
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ raise InsufficientQuotaError("quota exhausted")
+
+
+class PerItemLimitThenTransientErrorAsEmbedding:
+ """Reject an over-limit batch, then fail the first single-item request once.
+
+ Mirrors the review case: the batch is rejected because one item exceeds the per-item
+ window, and a sibling item then hits a transient provider failure while retried alone.
+ """
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+ max_chars = 32
+
+ def __init__(self, transient: Exception):
+ self.transient = transient
+ self.batch_sizes: list[int] = []
+ self.transient_failures = 0
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ raise ContextLengthError("the input length exceeds the context length")
+ if len(texts) == 1 and self.transient_failures == 0:
+ self.transient_failures += 1
+ raise self.transient
+ return [[1.0, 0.0] for _ in texts]
+
+
+class PerItemLimitThenQuotaAsEmbedding:
+ """Reject an over-limit batch, recover the first isolated item, then exhaust the quota.
+
+ Mirrors the review case: isolation starts, the first single-item request comes back, and
+ every later call then fails provider-wide. Asking for the remaining items would only
+ multiply the rejected requests, because no single item explains a quota failure.
+ """
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+ max_chars = 32
+
+ def __init__(self):
+ self.batch_sizes: list[int] = []
+ self.singles = 0
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ raise ContextLengthError("the input length exceeds the context length")
+ self.singles += 1
+ if self.singles > 1:
+ raise InsufficientQuotaError("quota exhausted")
+ return [[1.0, 0.0] for _ in texts]
+
+
+class AuthenticationError(Exception):
+ """OpenAI-compatible 401 error used without importing the provider SDK."""
+
+ status_code = 401
+
+
+class PermissionDeniedError(Exception):
+ """OpenAI-compatible 403 error used without importing the provider SDK."""
+
+ status_code = 403
+
+
+class InternalServerError(Exception):
+ """OpenAI-compatible 500 error used without importing the provider SDK."""
+
+ status_code = 500
+
+
+class NotFoundError(Exception):
+ """OpenAI-compatible 404 error, e.g. a missing model or endpoint."""
+
+ status_code = 404
+
+
+class APIError(Exception):
+ """Base of the OpenAI SDK error hierarchy, reproduced without importing the SDK."""
+
+
+class APIConnectionError(APIError):
+ """OpenAI SDK connection failure: it does not inherit from the built-in ConnectionError."""
+
+
+class APITimeoutError(APIConnectionError):
+ """OpenAI SDK timeout, a subclass of APIConnectionError."""
+
+
+class _RetryingPolicyModel:
+ """Stand-in for AgentScope's OpenAI embedding model: it retries transport errors."""
+
+ max_retries = 3
+
+ @classmethod
+ def _get_retryable_exceptions(cls):
+ """Mirror the OpenAI policy the real wrapper consults."""
+ return (APIConnectionError,)
+
+
+class AlwaysProviderWideFailureAsEmbedding:
+ """Always fail with the same provider-wide error, recording every requested batch size."""
+
+ dimensions = 2
+
+ def __init__(self, error: Exception):
+ self.error = error
+ self.batch_sizes: list[int] = []
+ # This double stands for an error raised by the SDK beneath an OpenAI-style model, so it
+ # advertises the same lower-layer retry policy: the store must not add a third loop.
+ self.model = _RetryingPolicyModel()
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ raise self.error
+
+
+class TransientThenSuccessAsEmbedding:
+ """Fail the first `failures` calls with one error, then return valid embeddings."""
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+
+ def __init__(self, error: Exception, failures: int = 1):
+ self.error = error
+ self.failures = failures
+ self.batch_sizes: list[int] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if len(self.batch_sizes) <= self.failures:
+ raise self.error
+ return [[1.0, 0.0] for _ in texts]
+
+
+_REAL_OPENAI_ASYNC_CLIENT = openai.AsyncClient
+_OFFLINE_OPENAI_BASE_URL = "http://127.0.0.1:9/v1"
+
+
+class CountingOpenAIWrapper(OpenAIAsEmbedding):
+ """Real OpenAI wrapper that records how often the store asked it for embeddings."""
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.calls = 0
+
+ async def __call__(self, inputs, **kwargs):
+ self.calls += 1
+ return await super().__call__(inputs, **kwargs)
+
+
+class OfflineOpenAIEndpoint:
+ """Real openai.AsyncClient wired to an offline httpx transport that counts provider attempts.
+
+ Gives the wrapper-and-SDK layers their real code path, so a regression test can assert how
+ many attempts the provider would see instead of trusting the store's own loop.
+ """
+
+ def __init__(self, handler, sdk_max_retries: int = 2) -> None:
+ self.requests = 0
+ self.handler = handler
+ self.client = _REAL_OPENAI_ASYNC_CLIENT(
+ api_key="test-key",
+ base_url=_OFFLINE_OPENAI_BASE_URL,
+ http_client=httpx.AsyncClient(transport=httpx.MockTransport(self._handle)),
+ max_retries=sdk_max_retries,
+ )
+
+ def _handle(self, request):
+ self.requests += 1
+ return self.handler(request)
+
+ def install(self, monkeypatch) -> "OfflineOpenAIEndpoint":
+ """Route every openai.AsyncClient built during this test to the offline client."""
+ monkeypatch.setattr(openai, "AsyncClient", lambda **kwargs: self.client)
+ # Exercise the real retry budget without spending wall-clock time on backoff.
+ monkeypatch.setattr(self.client, "_calculate_retry_timeout", lambda *_args, **_kwargs: 0.0)
+ return self
+
+
+def offline_openai_endpoint(handler) -> OfflineOpenAIEndpoint:
+ """Build an offline endpoint whose transport follows ``handler``."""
+ return OfflineOpenAIEndpoint(handler)
+
+
+def openai_wrapper(name: str) -> CountingOpenAIWrapper:
+ """Build a real OpenAI embedding wrapper pointed at the offline endpoint."""
+ return CountingOpenAIWrapper(
+ name=name,
+ model="text-embedding-v4",
+ backend="openai",
+ dimensions=2,
+ credential={"base_url": _OFFLINE_OPENAI_BASE_URL, "api_key": "test-key"},
+ retry_delay=0,
+ pass_dimensions=False,
+ )
+
+
+def embedding_payload(request) -> httpx.Response:
+ """Answer an embeddings request the way the provider would."""
+ body = json.loads(request.content.decode("utf-8"))
+ inputs = body["input"]
+ if isinstance(inputs, str):
+ inputs = [inputs]
+ return httpx.Response(
+ 200,
+ json={
+ "object": "list",
+ "data": [
+ {"object": "embedding", "index": position, "embedding": [0.0, 1.0]} for position in range(len(inputs))
+ ],
+ "model": body.get("model", "text-embedding-v4"),
+ "usage": {"prompt_tokens": len(inputs), "total_tokens": len(inputs)},
+ },
+ )
+
+
+def context_length_rejection() -> httpx.Response:
+ """Answer a batch the way an OpenAI-compatible provider reports an over-long input."""
+ return httpx.Response(
+ 400,
+ json={
+ "error": {
+ "message": (
+ "This model's maximum context length is 8191 tokens. " "Please reduce the length of the input."
+ ),
+ "type": "invalid_request_error",
+ "param": None,
+ "code": "context_length_exceeded",
+ },
+ },
+ )
+
+
+def read_timeout(request) -> httpx.Response:
+ """Fail one attempt the way a read timeout reaches the SDK."""
+ raise httpx.ReadTimeout("simulated read timeout", request=request)
+
+
class BadNodeEmbeddingStore(BaseEmbeddingStore):
"""Embedding store that returns wrong-dimensional vectors."""
@@ -156,176 +453,152 @@ def test_truncate_uses_cjk_aware_integer_budget():
assert store._truncate("中文") == ""
-def test_compute_batch_rejects_embeddings_with_wrong_dimension():
+@pytest.mark.asyncio
+async def test_compute_batch_rejects_embeddings_with_wrong_dimension():
"""Provider results with wrong dimensions are not padded, truncated, or cached."""
+ store = LocalEmbeddingStore(name="t_local_embedding_dim")
+ store.as_embedding = FakeAsEmbedding()
- async def go():
- store = LocalEmbeddingStore(name="t_local_embedding_dim")
- store.as_embedding = FakeAsEmbedding()
+ results = await store._compute_batch(
+ [
+ (0, "ok", "ok-cache-key"),
+ (1, "bad", "bad-cache-key"),
+ ],
+ )
- results = await store._compute_batch(
- [
- (0, "ok", "ok-cache-key"),
- (1, "bad", "bad-cache-key"),
- ],
- )
-
- assert len(results) == 1
- assert results[0][0] == 0
- assert results[0][2].tolist() == [1.0, 0.0]
- assert isinstance(results[0][2], np.ndarray)
-
- run(go())
+ assert len(results) == 1
+ assert results[0][0] == 0
+ assert results[0][2].tolist() == [1.0, 0.0]
+ assert isinstance(results[0][2], np.ndarray)
-def test_base_get_node_embeddings_rejects_wrong_dimension():
+@pytest.mark.asyncio
+async def test_base_get_node_embeddings_rejects_wrong_dimension():
"""Base node assignment should not accept wrong-dimensional vectors."""
+ store = BadNodeEmbeddingStore(name="t_base_embedding_dim")
+ node = EmbNode(text="bad")
- async def go():
- store = BadNodeEmbeddingStore(name="t_base_embedding_dim")
- node = EmbNode(text="bad")
+ await store.get_node_embeddings([node])
- await store.get_node_embeddings([node])
-
- assert node.embedding is None
-
- run(go())
+ assert node.embedding is None
-def test_health_check_rejects_wrong_dimension():
+@pytest.mark.asyncio
+async def test_health_check_rejects_wrong_dimension():
"""Health check should fail when the provider returns the wrong vector length."""
+ store = LocalEmbeddingStore(name="t_local_embedding_health_dim")
+ store.as_embedding = BadHealthAsEmbedding()
- async def go():
- store = LocalEmbeddingStore(name="t_local_embedding_health_dim")
- store.as_embedding = BadHealthAsEmbedding()
-
- assert await store.health_check() is False
- assert store.is_healthy is False
-
- run(go())
+ assert await store.health_check() is False
+ assert store.is_healthy is False
-def test_health_check_starts_timeout_after_provider_initialization(monkeypatch):
+@pytest.mark.asyncio
+async def test_health_check_starts_timeout_after_provider_initialization(monkeypatch):
"""One-time client construction must not consume the request timeout."""
+ events = []
- async def go():
- events = []
+ class InitializingAsEmbedding(FakeAsEmbedding):
+ """Record initialization and provider-call ordering."""
- class InitializingAsEmbedding(FakeAsEmbedding):
- """Record initialization and provider-call ordering."""
+ def initialize_model(self):
+ events.append("initialized")
- def initialize_model(self):
- events.append("initialized")
+ async def __call__(self, texts: list[str], **_kwargs):
+ events.append("remote request")
+ return [[1.0, 0.0] for _ in texts]
- async def __call__(self, texts: list[str], **_kwargs):
- events.append("remote request")
- return [[1.0, 0.0] for _ in texts]
+ original_wait_for = asyncio.wait_for
- original_wait_for = asyncio.wait_for
+ async def checked_wait_for(awaitable, timeout):
+ assert events == ["initialized"]
+ assert timeout == 5.0
+ return await original_wait_for(awaitable, timeout)
- async def checked_wait_for(awaitable, timeout):
- assert events == ["initialized"]
- assert timeout == 5.0
- return await original_wait_for(awaitable, timeout)
+ store = LocalEmbeddingStore(name="t_local_embedding_health_timeout_scope")
+ store.as_embedding = InitializingAsEmbedding()
+ monkeypatch.setattr(asyncio, "wait_for", checked_wait_for)
- store = LocalEmbeddingStore(name="t_local_embedding_health_timeout_scope")
- store.as_embedding = InitializingAsEmbedding()
- monkeypatch.setattr(asyncio, "wait_for", checked_wait_for)
-
- assert await store.health_check(timeout=5.0) is True
- assert events == ["initialized", "remote request"]
-
- run(go())
+ assert await store.health_check(timeout=5.0) is True
+ assert events == ["initialized", "remote request"]
-def test_health_check_makes_one_attempt():
+@pytest.mark.asyncio
+async def test_health_check_makes_one_attempt():
"""A failed startup probe does not add hidden retries."""
+ provider = FailingHealthAsEmbedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_health_retry",
+ health_check_timeout=3.0,
+ )
+ store.as_embedding = provider
- async def go():
- provider = FailingHealthAsEmbedding()
- store = LocalEmbeddingStore(
- name="t_local_embedding_health_retry",
- health_check_timeout=3.0,
- )
- store.as_embedding = provider
-
- assert await store.health_check() is False
- assert provider.calls == 1
-
- run(go())
+ assert await store.health_check() is False
+ assert provider.calls == 1
-def test_insufficient_quota_waits_sixty_seconds_before_retry(monkeypatch):
+@pytest.mark.asyncio
+async def test_insufficient_quota_waits_sixty_seconds_before_retry(monkeypatch):
"""Quota exhaustion uses the dedicated delay before ReMe retries."""
+ sleeps = []
- async def go():
- sleeps = []
+ async def fake_sleep(delay):
+ sleeps.append(delay)
- async def fake_sleep(delay):
- sleeps.append(delay)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_quota",
+ max_retries=2,
+ quota_retry_delay=60.0,
+ )
+ embedding = QuotaThenSuccessAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
- store = LocalEmbeddingStore(
- name="t_local_embedding_quota",
- max_retries=2,
- quota_retry_delay=60.0,
- )
- embedding = QuotaThenSuccessAsEmbedding()
- store.as_embedding = embedding
- monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+ result = (await store._call_with_retry(["text"])).embeddings
- result = await store._call_with_retry(["text"])
-
- assert result == [[1.0, 0.0]]
- assert embedding.calls == 2
- assert sleeps == [60.0]
-
- run(go())
+ assert result == [[1.0, 0.0]]
+ assert embedding.calls == 2
+ assert sleeps == [60.0]
-def test_insufficient_quota_does_not_retry_without_opt_in(monkeypatch):
+@pytest.mark.asyncio
+async def test_insufficient_quota_does_not_retry_without_opt_in(monkeypatch):
"""The default store behavior remains unchanged for embedded consumers."""
+ sleeps = []
- async def go():
- sleeps = []
+ async def fake_sleep(delay):
+ sleeps.append(delay)
- async def fake_sleep(delay):
- sleeps.append(delay)
+ store = LocalEmbeddingStore(name="t_local_embedding_default_quota", max_retries=2)
+ embedding = QuotaThenSuccessAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
- store = LocalEmbeddingStore(name="t_local_embedding_default_quota", max_retries=2)
- embedding = QuotaThenSuccessAsEmbedding()
- store.as_embedding = embedding
- monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+ result = (await store._call_with_retry(["text"])).embeddings
- result = await store._call_with_retry(["text"])
-
- assert result is None
- assert embedding.calls == 1
- assert not sleeps
-
- run(go())
+ assert result is None
+ assert embedding.calls == 1
+ assert not sleeps
-def test_rate_limit_retries_with_backoff_without_opt_in(monkeypatch):
+@pytest.mark.asyncio
+async def test_rate_limit_retries_with_backoff_without_opt_in(monkeypatch):
"""A 429 must retry like a network error, with no quota_retry_delay required."""
+ sleeps = []
- async def go():
- sleeps = []
+ async def fake_sleep(delay):
+ sleeps.append(delay)
- async def fake_sleep(delay):
- sleeps.append(delay)
+ store = LocalEmbeddingStore(name="t_local_embedding_rate_limit", max_retries=2)
+ embedding = RateLimitedThenSuccessAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
- store = LocalEmbeddingStore(name="t_local_embedding_rate_limit", max_retries=2)
- embedding = RateLimitedThenSuccessAsEmbedding()
- store.as_embedding = embedding
- monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+ result = (await store._call_with_retry(["text"])).embeddings
- result = await store._call_with_retry(["text"])
-
- assert result == [[1.0, 0.0]]
- assert embedding.calls == 2
- assert sleeps == [1.0]
-
- run(go())
+ assert result == [[1.0, 0.0]]
+ assert embedding.calls == 2
+ assert sleeps == [1.0]
def test_is_rate_limited_recognizes_status_code_or_body_and_nothing_else():
@@ -341,40 +614,37 @@ def test_is_rate_limited_recognizes_status_code_or_body_and_nothing_else():
body = {"error": {"code": "rate_limit_exceeded"}}
- assert LocalEmbeddingStore._is_rate_limited(StatusCodeError())
- assert LocalEmbeddingStore._is_rate_limited(BodyCodeError())
- assert not LocalEmbeddingStore._is_rate_limited(ValueError("unrelated"))
+ assert _EmbeddingError.from_exception(StatusCodeError()).is_rate_limited
+ assert _EmbeddingError.from_exception(BodyCodeError()).is_rate_limited
+ assert not _EmbeddingError.from_exception(ValueError("unrelated")).is_rate_limited
def test_is_rate_limited_defers_to_insufficient_quota_on_status_code_429():
"""An insufficient_quota error carrying status_code=429 is not the generic rate-limit case."""
- assert not LocalEmbeddingStore._is_rate_limited(InsufficientQuotaError("quota exhausted"))
- assert LocalEmbeddingStore._is_insufficient_quota(InsufficientQuotaError("quota exhausted"))
+ assert not _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_rate_limited
+ assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_insufficient_quota
-def test_rate_limit_exhausts_retries_and_reports_unhealthy(monkeypatch):
+@pytest.mark.asyncio
+async def test_rate_limit_exhausts_retries_and_reports_unhealthy(monkeypatch):
"""Repeated 429s still give up after max_retries, unlike an unclassified error."""
+ sleeps = []
- async def go():
- sleeps = []
+ async def fake_sleep(delay):
+ sleeps.append(delay)
- async def fake_sleep(delay):
- sleeps.append(delay)
+ store = LocalEmbeddingStore(name="t_local_embedding_rate_limit_exhausted", max_retries=3)
+ embedding = AlwaysRateLimitedAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
- store = LocalEmbeddingStore(name="t_local_embedding_rate_limit_exhausted", max_retries=3)
- embedding = AlwaysRateLimitedAsEmbedding()
- store.as_embedding = embedding
- monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+ result = (await store._call_with_retry(["text"])).embeddings
- result = await store._call_with_retry(["text"])
-
- assert result is None
- assert store.is_healthy is False
- assert embedding.calls == 3
- assert sleeps == [1.0, 2.0]
-
- run(go())
+ assert result is None
+ assert store.is_healthy is False
+ assert embedding.calls == 3
+ assert sleeps == [1.0, 2.0]
def test_vector_space_id_separates_models_of_equal_dimension():
@@ -545,188 +815,1188 @@ def test_ollama_vector_space_id_uses_sdk_default_endpoint(monkeypatch):
assert embedding.vector_space_id == before
-def test_cache_is_saved_and_restored_per_vector_space(monkeypatch, tmp_path):
+@pytest.mark.asyncio
+async def test_cache_is_saved_and_restored_per_vector_space(monkeypatch, tmp_path):
"""Switching models persists the old cache and restores it when switched back."""
+ monkeypatch.setattr(
+ LocalEmbeddingStore,
+ "component_metadata_path",
+ property(lambda _self: tmp_path),
+ )
+ embedding = OpenAIAsEmbedding(name="t_space_store", backend="openai", model="v3", dimensions=2)
+ store = LocalEmbeddingStore(name="t_local_space")
+ store.as_embedding = embedding
+ await store.load()
- async def go():
- monkeypatch.setattr(
- LocalEmbeddingStore,
- "component_metadata_path",
- property(lambda _self: tmp_path),
- )
- embedding = OpenAIAsEmbedding(name="t_space_store", backend="openai", model="v3", dimensions=2)
- store = LocalEmbeddingStore(name="t_local_space")
- store.as_embedding = embedding
- await store.load()
+ key = store._cache_key("hello")
+ store._cache_put(key, np.array([1.0, 0.0], dtype=np.float16))
+ v3_path = store.cache_path
- key = store._cache_key("hello")
- store._cache_put(key, np.array([1.0, 0.0], dtype=np.float16))
- v3_path = store.cache_path
+ embedding.model = FakeProviderModel("v4")
+ await store._sync_cache_space()
- embedding.model = FakeProviderModel("v4")
- await store._sync_cache_space()
+ assert store._cache_key("hello") == key
+ assert store.cache_path != v3_path
+ assert store._cache_get(store._cache_key("hello")) is None
+ assert v3_path.exists()
- assert store._cache_key("hello") == key
- assert store.cache_path != v3_path
- assert store._cache_get(store._cache_key("hello")) is None
- assert v3_path.exists()
+ embedding.model = FakeProviderModel("v3")
+ await store._sync_cache_space()
- embedding.model = FakeProviderModel("v3")
- await store._sync_cache_space()
-
- np.testing.assert_array_equal(store._cache_get(key), np.array([1.0, 0.0], dtype=np.float16))
-
- run(go())
+ np.testing.assert_array_equal(store._cache_get(key), np.array([1.0, 0.0], dtype=np.float16))
-def test_cache_space_is_rechecked_after_async_load(monkeypatch, tmp_path):
+@pytest.mark.asyncio
+async def test_cache_space_is_rechecked_after_async_load(monkeypatch, tmp_path):
"""A provider switch during disk I/O must not publish the stale namespace."""
+ monkeypatch.setattr(
+ LocalEmbeddingStore,
+ "component_metadata_path",
+ property(lambda _self: tmp_path),
+ )
+ embedding = OpenAIAsEmbedding(name="t_space_load_race", backend="openai", model="v3", dimensions=2)
+ store = LocalEmbeddingStore(name="t_local_load_race")
+ store.as_embedding = embedding
+ await store.load()
- async def go():
- monkeypatch.setattr(
- LocalEmbeddingStore,
- "component_metadata_path",
- property(lambda _self: tmp_path),
- )
- embedding = OpenAIAsEmbedding(name="t_space_load_race", backend="openai", model="v3", dimensions=2)
- store = LocalEmbeddingStore(name="t_local_load_race")
- store.as_embedding = embedding
- await store.load()
+ embedding.model = FakeProviderModel("v4")
+ v4_space = embedding.vector_space_id
+ np.savez(
+ store._cache_path(v4_space),
+ keys=np.array([store._cache_key("hello")]),
+ embeddings=np.array([[4.0, 0.0]], dtype=np.float16),
+ )
+ original_to_thread = asyncio.to_thread
- embedding.model = FakeProviderModel("v4")
- v4_space = embedding.vector_space_id
- np.savez(
- store._cache_path(v4_space),
- keys=np.array([store._cache_key("hello")]),
- embeddings=np.array([[4.0, 0.0]], dtype=np.float16),
- )
- original_to_thread = asyncio.to_thread
+ async def switch_during_load(func, *args):
+ if getattr(func, "__name__", "") == "_load_sync":
+ embedding.model = FakeProviderModel("v3")
+ return await original_to_thread(func, *args)
- async def switch_during_load(func, *args):
- if getattr(func, "__name__", "") == "_load_sync":
- embedding.model = FakeProviderModel("v3")
- return await original_to_thread(func, *args)
+ monkeypatch.setattr(asyncio, "to_thread", switch_during_load)
+ await store._sync_cache_space()
- monkeypatch.setattr(asyncio, "to_thread", switch_during_load)
- await store._sync_cache_space()
-
- assert store._cache_space == embedding.vector_space_id
- assert not store._cache
-
- run(go())
+ assert store._cache_space == embedding.vector_space_id
+ assert not store._cache
-def test_whole_request_retries_after_vector_space_changes_between_batches():
+@pytest.mark.asyncio
+async def test_whole_request_retries_after_vector_space_changes_between_batches():
"""Completed batches must be discarded when a later batch changes vector space."""
+ embedding = FakeAsEmbedding()
+ embedding.vector_space_id = "v3"
+ store = LocalEmbeddingStore(name="t_local_write_race", max_batch_size=1, enable_cache=False)
+ store.as_embedding = embedding
+ store._cache_space = embedding.vector_space_id
+ calls = 0
- async def go():
- embedding = FakeAsEmbedding()
- embedding.vector_space_id = "v3"
- store = LocalEmbeddingStore(name="t_local_write_race", max_batch_size=1, enable_cache=False)
- store.as_embedding = embedding
- store._cache_space = embedding.vector_space_id
- calls = 0
+ async def switch_during_second_batch(batch, **_kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 2:
+ embedding.vector_space_id = "v4"
+ idx, _text, key = batch[0]
+ version = 3.0 if calls < 3 else 4.0
+ return [(idx, key, np.array([version, 0.0], dtype=np.float16))]
- async def switch_during_second_batch(batch, **_kwargs):
- nonlocal calls
- calls += 1
- if calls == 2:
- embedding.vector_space_id = "v4"
- idx, _text, key = batch[0]
- version = 3.0 if calls < 3 else 4.0
- return [(idx, key, np.array([version, 0.0], dtype=np.float16))]
+ store._compute_batch = switch_during_second_batch
+ results = await store.get_embeddings(["first", "second"])
- store._compute_batch = switch_during_second_batch
- results = await store.get_embeddings(["first", "second"])
-
- assert calls == 4
- assert store._cache_space == embedding.vector_space_id
- for result in results:
- np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
-
- run(go())
+ assert calls == 4
+ assert store._cache_space == embedding.vector_space_id
+ for result in results:
+ np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
-def test_whole_request_rereads_cache_after_vector_space_changes(monkeypatch, tmp_path):
+@pytest.mark.asyncio
+async def test_whole_request_rereads_cache_after_vector_space_changes(monkeypatch, tmp_path):
"""A cache hit from the old space must not survive a later provider switch."""
+ monkeypatch.setattr(
+ LocalEmbeddingStore,
+ "component_metadata_path",
+ property(lambda _self: tmp_path),
+ )
+ embedding = FakeAsEmbedding()
+ embedding.vector_space_id = "v3"
+ store = LocalEmbeddingStore(name="t_local_cache_race", enable_cache=True)
+ store.as_embedding = embedding
+ store._cache_space = embedding.vector_space_id
+ first_key = store._cache_key("first")
+ store._cache[first_key] = np.array([3.0, 0.0], dtype=np.float16)
+ calls = 0
- async def go():
- monkeypatch.setattr(
- LocalEmbeddingStore,
- "component_metadata_path",
- property(lambda _self: tmp_path),
- )
- embedding = FakeAsEmbedding()
- embedding.vector_space_id = "v3"
- store = LocalEmbeddingStore(name="t_local_cache_race", enable_cache=True)
- store.as_embedding = embedding
- store._cache_space = embedding.vector_space_id
- first_key = store._cache_key("first")
- store._cache[first_key] = np.array([3.0, 0.0], dtype=np.float16)
- calls = 0
+ async def switch_on_miss(batch, **_kwargs):
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ embedding.vector_space_id = "v4"
+ version = 3.0 if calls == 1 else 4.0
+ return [(idx, key, np.array([version, 0.0], dtype=np.float16)) for idx, _text, key in batch]
- async def switch_on_miss(batch, **_kwargs):
- nonlocal calls
- calls += 1
- if calls == 1:
- embedding.vector_space_id = "v4"
- version = 3.0 if calls == 1 else 4.0
- return [(idx, key, np.array([version, 0.0], dtype=np.float16)) for idx, _text, key in batch]
+ store._compute_batch = switch_on_miss
+ results = await store.get_embeddings(["first", "second"])
- store._compute_batch = switch_on_miss
- results = await store.get_embeddings(["first", "second"])
-
- assert calls == 2
- for result in results:
- np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
-
- run(go())
+ assert calls == 2
+ for result in results:
+ np.testing.assert_array_equal(result, np.array([4.0, 0.0], dtype=np.float16))
-def test_whole_request_stops_retrying_when_vector_space_keeps_changing():
+@pytest.mark.asyncio
+async def test_whole_request_stops_retrying_when_vector_space_keeps_changing():
"""Continuous configuration churn must discard the whole request instead of blocking forever."""
+ embedding = FakeAsEmbedding()
+ embedding.vector_space_id = "v3"
+ store = LocalEmbeddingStore(name="t_local_write_churn")
+ store.as_embedding = embedding
+ store._cache_space = embedding.vector_space_id
+ calls = 0
- async def go():
- embedding = FakeAsEmbedding()
- embedding.vector_space_id = "v3"
- store = LocalEmbeddingStore(name="t_local_write_churn")
- store.as_embedding = embedding
- store._cache_space = embedding.vector_space_id
- calls = 0
+ async def change_space_every_time(_batch, **_kwargs):
+ nonlocal calls
+ calls += 1
+ embedding.vector_space_id = f"v{calls + 3}"
+ return [(0, "key", np.array([float(calls), 0.0], dtype=np.float16))]
- async def change_space_every_time(_batch, **_kwargs):
- nonlocal calls
- calls += 1
- embedding.vector_space_id = f"v{calls + 3}"
- return [(0, "key", np.array([float(calls), 0.0], dtype=np.float16))]
+ store._compute_batch = change_space_every_time
+ results = await store.get_embeddings(["text"])
- store._compute_batch = change_space_every_time
- results = await store.get_embeddings(["text"])
-
- assert calls == 3
- assert results == [None]
- assert "key" not in store._cache
-
- run(go())
+ assert calls == 3
+ assert results == [None]
+ assert "key" not in store._cache
-def test_start_ignores_cache_file_without_vector_space_tag(monkeypatch, tmp_path):
+@pytest.mark.asyncio
+async def test_start_ignores_cache_file_without_vector_space_tag(monkeypatch, tmp_path):
"""An unattributable legacy cache is ignored without deleting derived data."""
+ monkeypatch.setattr(
+ LocalEmbeddingStore,
+ "component_metadata_path",
+ property(lambda _self: tmp_path),
+ )
+ store = LocalEmbeddingStore(name="t_local_untagged")
+ store.as_embedding = FakeAsEmbedding()
+ untagged = tmp_path / f"{store.name}_{store.cache_version}.npz"
+ untagged.write_bytes(b"vectors from an unknown model")
- async def go():
- monkeypatch.setattr(
- LocalEmbeddingStore,
- "component_metadata_path",
- property(lambda _self: tmp_path),
+ await store._start()
+
+ assert untagged.exists()
+ assert not store._cache
+
+
+@pytest.mark.asyncio
+async def test_batch_failure_falls_back_to_per_item_embedding():
+ """One over-limit item must cost one vector, not its whole batch."""
+ provider = PerItemLimitAsEmbedding()
+ store = LocalEmbeddingStore(name="t_local_embedding_poison_batch", enable_cache=False)
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
+
+ assert results == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert provider.batch_sizes == [3, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_poison_chunk_keeps_its_batch_mates():
+ """A rejected chunk must not clear the vectors of the chunks beside it."""
+ store = LocalEmbeddingStore(name="t_local_embedding_poison_nodes", enable_cache=False)
+ store.as_embedding = PerItemLimitAsEmbedding()
+ nodes = [EmbNode(text="first"), EmbNode(text="y" * 64), EmbNode(text="third")]
+
+ await store.get_node_embeddings(nodes)
+
+ assert nodes[0].embedding is not None
+ assert nodes[1].embedding is None
+ assert nodes[2].embedding is not None
+
+
+@pytest.mark.asyncio
+async def test_single_item_failure_still_reports_a_failed_batch():
+ """A one-item request has nothing to isolate, so the existing contract stands."""
+ store = LocalEmbeddingStore(name="t_local_embedding_single_poison", enable_cache=False)
+ store.as_embedding = PerItemLimitAsEmbedding()
+
+ result = await store._call_with_retry(["x" * 64])
+ assert result.embeddings is None
+ assert result.terminal_failure is False
+
+
+@pytest.mark.asyncio
+async def test_rate_limited_batch_is_not_split_into_per_item_requests(monkeypatch):
+ """A 429 is a provider-level failure; splitting it would multiply the load."""
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ store = LocalEmbeddingStore(name="t_local_embedding_rate_limit_batch", max_retries=2)
+ embedding = AlwaysRateLimitedAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ assert (await store._call_with_retry(["a", "b"])).embeddings is None
+ assert embedding.calls == 2
+ assert sleeps == [1.0]
+
+
+@pytest.mark.asyncio
+async def test_insufficient_quota_batch_is_not_split_into_per_item_requests(monkeypatch):
+ """Quota exhaustion is a service-level failure; splitting it would multiply the load."""
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ store = LocalEmbeddingStore(name="t_local_embedding_quota_batch", max_retries=2)
+ embedding = AlwaysInsufficientQuotaAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
+ assert embedding.batch_sizes == [3]
+ assert store.is_healthy is False
+ assert not sleeps
+
+
+@pytest.mark.asyncio
+async def test_exhausted_quota_retries_still_do_not_split_the_batch(monkeypatch):
+ """Once the opt-in quota retries run out, the store gives up instead of splitting."""
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_quota_batch_exhausted",
+ max_retries=3,
+ quota_retry_delay=5.0,
+ )
+ embedding = AlwaysInsufficientQuotaAsEmbedding()
+ store.as_embedding = embedding
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ assert (await store._call_with_retry(["a", "b"])).embeddings is None
+ assert embedding.batch_sizes == [2, 2, 2]
+ assert sleeps == [5.0, 5.0]
+
+
+@pytest.mark.asyncio
+async def test_per_item_fallback_keeps_a_rate_limited_vector(monkeypatch):
+ """A 429 on one isolated item still gets the store's backoff and keeps its vector.
+
+ Rate limiting is the failure class the store still owns: no lower layer reports it as a
+ provider-wide error worth its own budget, so the retry lives here. Connection errors and
+ timeouts are left to the wrapper and the SDK instead; see the transport tests above.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ provider = PerItemLimitThenTransientErrorAsEmbedding(
+ RateLimitError("Requests are too frequent"),
+ )
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_fallback_rate_limit",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
+
+ assert results == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert provider.batch_sizes == [3, 1, 1, 1, 1]
+ assert provider.transient_failures == 1
+ assert sleeps == [1.0]
+
+
+@pytest.mark.asyncio
+async def test_sdk_transport_errors_are_provider_wide_and_not_retried_here(monkeypatch):
+ """SDK connection/timeout errors are provider-wide: never split, and not retried here.
+
+ The wrapper and the SDK already retry a transport failure, so the store answers once and
+ leaves the retry budget to those layers instead of multiplying their attempts.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ for error in (APIConnectionError("connection reset"), APITimeoutError("timed out")):
+ sleeps.clear()
+ provider = AlwaysProviderWideFailureAsEmbedding(error)
+ store = LocalEmbeddingStore(name="t_local_embedding_sdk_transport", max_retries=3)
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry([f"item{i}" for i in range(10)])).embeddings is None
+ # One request only: the wrapper and the SDK already retry a transport failure
+ # (4 wrapper attempts, each retried by the SDK), so another loop here only
+ # multiplies them -- a persistent timeout used to cost 36 attempts instead of 12.
+ assert provider.batch_sizes == [10]
+ assert not sleeps
+ assert store.is_healthy is False
+
+
+@pytest.mark.asyncio
+async def test_a_provider_without_a_retry_policy_keeps_the_store_backoff(monkeypatch):
+ """A provider with no lower-layer retry policy still gets the store's backoff.
+
+ This fake exposes no ``model`` with a retry policy, which is the same position Ollama is
+ in: its embedding model inherits an empty retryable tuple and its SDK turns a connection
+ error into a plain ``ConnectionError`` without retrying. The store's retry is therefore the
+ only layer that can keep that vector.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ for transient in (
+ APIConnectionError("connection reset"),
+ ConnectionError("connection reset"),
+ TimeoutError("request timed out"),
+ ):
+ sleeps.clear()
+
+ provider = PerItemLimitThenTransientErrorAsEmbedding(transient)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_sdk_isolated",
+ max_retries=3,
+ enable_cache=False,
)
- store = LocalEmbeddingStore(name="t_local_untagged")
- store.as_embedding = FakeAsEmbedding()
- untagged = tmp_path / f"{store.name}_{store.cache_version}.npz"
- untagged.write_bytes(b"vectors from an unknown model")
+ store.as_embedding = provider
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
- await store._start()
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
- assert untagged.exists()
- assert not store._cache
+ assert results == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert provider.batch_sizes == [3, 1, 1, 1, 1]
+ assert sleeps == [1.0]
- run(go())
+
+@pytest.mark.asyncio
+async def test_a_persistent_transport_error_is_not_multiplied_by_the_store(monkeypatch):
+ """A timeout is asked once: the wrapper and the SDK own the retry budget.
+
+ The provider sees the 4 wrapper attempts x 3 SDK attempts those layers make, while the
+ store's max_retries=3 no longer multiplies them -- a persistent timeout used to cost 36
+ provider attempts instead of 12.
+ """
+ endpoint = offline_openai_endpoint(read_timeout).install(monkeypatch)
+ provider = openai_wrapper("t_openai_transport_budget")
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_sdk_transport_budget",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry([f"item{i}" for i in range(3)])).embeddings is None
+ assert provider.calls == 1
+ assert endpoint.requests == 12
+ assert store.is_healthy is False
+
+
+@pytest.mark.asyncio
+async def test_a_transient_transport_error_still_recovers_through_the_sdk(monkeypatch):
+ """One timeout is answered by the SDK's own retry: the batch survives and never splits."""
+ state = {"timeouts": 1}
+
+ def handler(request):
+ if state["timeouts"] > 0:
+ state["timeouts"] -= 1
+ return read_timeout(request)
+ return embedding_payload(request)
+
+ endpoint = offline_openai_endpoint(handler).install(monkeypatch)
+ provider = openai_wrapper("t_openai_transport_recover")
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_sdk_transport_recover",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry([f"item{i}" for i in range(3)])).embeddings
+
+ assert results == [[0.0, 1.0]] * 3
+ assert provider.calls == 1
+ assert endpoint.requests == 2
+
+
+@pytest.mark.asyncio
+async def test_an_isolated_item_still_recovers_through_the_sdk(monkeypatch):
+ """After a context-length rejection, a timeout on one remaining item is still recovered.
+
+ The store asks the wrapper once per item; the retry that saves that item belongs to the SDK.
+ """
+ state = {"timeout_pending": True}
+
+ def handler(request):
+ body = json.loads(request.content.decode("utf-8"))
+ inputs = body["input"]
+ if isinstance(inputs, str):
+ inputs = [inputs]
+ if any(len(item) > 32 for item in inputs):
+ return context_length_rejection()
+ if len(inputs) == 1 and state["timeout_pending"]:
+ state["timeout_pending"] = False
+ return read_timeout(request)
+ return embedding_payload(request)
+
+ endpoint = offline_openai_endpoint(handler).install(monkeypatch)
+ provider = openai_wrapper("t_openai_transport_isolated")
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_sdk_transport_isolated",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
+
+ assert results == [[0.0, 1.0], None, [0.0, 1.0]]
+ assert provider.calls == 4
+ assert endpoint.requests == 5
+
+
+@pytest.mark.asyncio
+async def test_provider_wide_statuses_exclude_input_dependent_bad_request():
+ """404 is provider-wide, but an over-limit 400 must stay input-dependent."""
+ for error in (
+ AuthenticationError("bad key"),
+ PermissionDeniedError("no access"),
+ NotFoundError("model_not_found"),
+ InternalServerError("boom"),
+ ):
+ assert _EmbeddingError.from_exception(error).is_provider_wide is True
+ over_limit = ContextLengthError("the input length exceeds the context length")
+ assert _EmbeddingError.from_exception(over_limit).is_provider_wide is False
+
+
+class GenAiClientError(Exception):
+ """Google GenAI client error: the HTTP status is an integer ``code``, not ``status_code``."""
+
+ def __init__(self, code: int, message: str = "client error"):
+ super().__init__(message)
+ self.code = code
+
+
+class GenAiServerError(Exception):
+ """Google GenAI server error: the HTTP status is an integer ``code``, not ``status_code``."""
+
+ def __init__(self, code: int, message: str = "server error"):
+ super().__init__(message)
+ self.code = code
+
+
+class StringCodeError(Exception):
+ """OpenAI-compatible error whose ``code`` names the error instead of carrying a status."""
+
+ code = "invalid_api_key"
+
+
+class BadRequestError(Exception):
+ """OpenAI-compatible 400 that names no input, such as a malformed payload."""
+
+ status_code = 400
+
+
+class ResponseLike:
+ """httpx-style wrapper that keeps the status on a ``response`` attribute."""
+
+ def __init__(self, status_code: int):
+ self.status_code = status_code
+
+
+class WrappedTransportError(Exception):
+ """Error that only exposes its status through ``response.status_code``."""
+
+ def __init__(self, status_code: int):
+ super().__init__("wrapped")
+ self.response = ResponseLike(status_code)
+
+
+class DashScopeRuntimeError(RuntimeError):
+ """AgentScope turns every non-200 DashScope response into a plain ``RuntimeError``.
+
+ The structured response is embedded in the message instead of being kept as an attribute,
+ so a client that only reads exception attributes cannot classify it.
+ """
+
+ @staticmethod
+ def for_status(status_code: int, code: str = "InvalidApiKey") -> "DashScopeRuntimeError":
+ """Build the exception AgentScope raises for a non-200 DashScope response."""
+ response = {
+ "status_code": status_code,
+ "request_id": "req-0",
+ "code": code,
+ "message": "request rejected",
+ "output": None,
+ "usage": None,
+ }
+ return DashScopeRuntimeError(f"Failed to get embedding from DashScope API: {json.dumps(response)}")
+
+
+def test_http_status_reads_every_supported_exception_shape():
+ """Read the status from status_code, an integer code, response.status_code or the message."""
+
+ assert _EmbeddingError.from_exception(AuthenticationError("bad key")).status == 401
+ assert _EmbeddingError.from_exception(NotFoundError("model_not_found")).status == 404
+ assert _EmbeddingError.from_exception(GenAiClientError(401)).status == 401
+ assert _EmbeddingError.from_exception(GenAiServerError(503)).status == 503
+ assert _EmbeddingError.from_exception(WrappedTransportError(404)).status == 404
+ assert _EmbeddingError.from_exception(DashScopeRuntimeError.for_status(401)).status == 401
+ assert _EmbeddingError.from_exception(ValueError("no status here")).status is None
+
+
+def test_http_status_ignores_a_string_code():
+ """A string ``code`` names the error; reading it as a status would misclassify quota errors."""
+
+ assert _EmbeddingError.from_exception(StringCodeError("invalid key")).status is None
+ assert _EmbeddingError.from_exception(StringCodeError("invalid key")).is_insufficient_quota is False
+ assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).status == 429
+ assert _EmbeddingError.from_exception(InsufficientQuotaError("quota exhausted")).is_insufficient_quota is True
+
+
+def test_input_dependent_classifier_pins_both_halves_of_the_boundary():
+ """The isolation must keep working for the one error a single item can explain."""
+
+ assert (
+ _EmbeddingError.from_exception(
+ ContextLengthError("the input length exceeds the context length"),
+ ).is_input_dependent
+ is True
+ )
+ assert _EmbeddingError.from_exception(ContextLengthError("too long")).is_input_dependent is True
+ assert _EmbeddingError.from_exception(AuthenticationError("bad key")).is_input_dependent is False
+ assert _EmbeddingError.from_exception(InternalServerError("boom")).is_input_dependent is False
+ assert _EmbeddingError.from_exception(GenAiClientError(400)).is_input_dependent is False
+ assert _EmbeddingError.from_exception(RuntimeError("provider said no")).is_input_dependent is False
+
+
+@pytest.mark.asyncio
+async def test_bad_request_without_an_input_length_signal_is_not_split():
+ """A bare 400 does not name an input, so the batch is not divided over it."""
+ provider = AlwaysProviderWideFailureAsEmbedding(BadRequestError("malformed payload"))
+ store = LocalEmbeddingStore(name="t_local_embedding_bare_400", max_retries=2)
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
+ assert provider.batch_sizes == [3]
+
+
+@pytest.mark.asyncio
+async def test_isolation_stops_when_a_provider_wide_failure_hits():
+ """Once isolation meets a provider-wide failure, the remaining items are not requested.
+
+ The vectors already recovered stay in the result: only the items that were never answered
+ are dropped, and no further request is sent for them.
+ """
+ provider = PerItemLimitThenQuotaAsEmbedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_isolation_stops",
+ max_retries=2,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
+
+ assert result.embeddings == [[1.0, 0.0], None, None, None]
+ assert result.terminal_failure is True
+ # One rejected batch, the recovered first item, the over-limit item, then the quota
+ # failure. The fourth item is never requested once the quota failure is seen.
+ assert provider.batch_sizes == [4, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_partial_success_keeps_a_failed_provider_unhealthy():
+ """Recovered vectors must not overwrite the health state of a provider-wide failure.
+
+ Isolation can recover an early item and then meet a provider-wide failure such as
+ exhausted quota. ``_call_with_retry`` marks the store unhealthy and stops, but the
+ vectors recovered before it are still returned. Treating that partial success as a
+ healthy provider would hide the outage, and it would break the unhealthy-to-healthy
+ transition the file store uses to schedule missing-vector backfill. The existing
+ isolation test drives ``_call_with_retry`` directly, so it never sees the overwrite.
+ """
+ provider = PerItemLimitThenQuotaAsEmbedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_partial_health",
+ max_retries=2,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ vectors = await store.get_embeddings(["ok", "x" * 64, "also ok", "still ok"])
+
+ # The vector recovered before the outage is kept ...
+ assert vectors[0] is not None
+ assert vectors[1] is None and vectors[2] is None and vectors[3] is None
+ # ... and the provider-wide failure still decides the health state.
+ assert store.is_healthy is False
+ # One rejected batch, the recovered first item, the over-limit item, then the quota
+ # failure: the fourth item is never requested.
+ assert provider.batch_sizes == [4, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_isolation_keeps_going_while_only_inputs_fail():
+ """An input-dependent failure is local to its item, so isolation must continue.
+
+ This pins the other half of the boundary: stopping on every isolated failure would lose
+ the vectors of the items that are perfectly embeddable on their own.
+ """
+ provider = PerItemLimitAsEmbedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_isolation_keeps_going",
+ max_retries=2,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["ok", "x" * 64, "also ok", "still ok"])
+
+ assert result.embeddings == [[1.0, 0.0], None, [1.0, 0.0], [1.0, 0.0]]
+ assert result.terminal_failure is False
+ assert provider.batch_sizes == [4, 1, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_dashscope_serialized_quota_errors_keep_their_quota_policy(monkeypatch):
+ """A serialized DashScope quota error must classify as quota, not as a rate limit.
+
+ AgentScope embeds the structured response in the message, so the business error code has to
+ be read back out of it. Without that the opt-in quota retry policy is never applied and the
+ error is retried with the generic backoff instead.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ error = DashScopeRuntimeError.for_status(429, code="insufficient_quota")
+ assert _EmbeddingError.from_exception(error).is_insufficient_quota is True
+ assert _EmbeddingError.from_exception(error).is_rate_limited is False
+
+ provider = AlwaysProviderWideFailureAsEmbedding(error)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_dashscope_quota",
+ max_retries=3,
+ quota_retry_delay=7.0,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry(["a", "b"])).embeddings is None
+ assert provider.batch_sizes == [2, 2, 2]
+ assert sleeps == [7.0, 7.0]
+
+
+class ContextLength413Error(Exception):
+ """An over-limit input reported as HTTP 413 instead of 400.
+
+ Ollama v0.40.3 normalizes an embedding input-limit rejection to 413 and propagates that
+ status through the embedding endpoint, so the same over-long item that arrives as a 400
+ from an OpenAI-compatible gateway is a 413 for that provider.
+ """
+
+ status_code = 413
+ body = {"error": {"message": "the input length exceeds the context length"}}
+
+
+class RequestBodyTooLargeError(Exception):
+ """A bare 413 that never names an input: the request body itself is too large."""
+
+ status_code = 413
+
+
+class PerItemLimitAs413Embedding:
+ """Reject a whole request with HTTP 413 when any single text is over the per-item limit."""
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+ max_chars = 32
+
+ def __init__(self):
+ self.batch_sizes: list[int] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ raise ContextLength413Error("the input length exceeds the context length")
+ return [[1.0, 0.0] for _ in texts]
+
+
+@pytest.mark.asyncio
+async def test_context_length_rejection_reported_as_http_413_is_isolated():
+ """An over-limit input carried by a 413 must still reach the per-item isolation path.
+
+ Ollama normalizes an embedding input-limit rejection to 413, so a classifier that only
+ accepts 400 rejects the batch instead of isolating the item that caused it, and every
+ healthy vector in the request is lost.
+ """
+ provider = PerItemLimitAs413Embedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_413_isolated",
+ max_retries=2,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
+
+ assert results == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert provider.batch_sizes == [3, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_bare_http_413_without_an_input_signal_is_not_split():
+ """A 413 that names no input stays provider-wide, so the batch is not divided over it.
+
+ The status alone is ambiguous: it can describe an unrelated request-body limit, which no
+ single input explains, so only a 413 that blames the input itself may fan out per item.
+ """
+ provider = AlwaysProviderWideFailureAsEmbedding(
+ RequestBodyTooLargeError("request entity too large"),
+ )
+ store = LocalEmbeddingStore(name="t_local_embedding_bare_413", max_retries=2)
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry(["a", "b", "c"])).embeddings is None
+ assert provider.batch_sizes == [3]
+
+
+def test_input_dependent_classifier_accepts_a_413_only_with_an_input_signal():
+ """Pin both halves of the 413 boundary, and that the 400 behaviour is unchanged."""
+
+ assert (
+ _EmbeddingError.from_exception(
+ ContextLength413Error("the input length exceeds the context length"),
+ ).is_input_dependent
+ is True
+ )
+ assert _EmbeddingError.from_exception(ContextLength413Error("too long")).is_input_dependent is True
+ assert (
+ _EmbeddingError.from_exception(RequestBodyTooLargeError("request entity too large")).is_input_dependent is False
+ )
+ assert _EmbeddingError.from_exception(ContextLengthError("too long")).is_input_dependent is True
+ assert _EmbeddingError.from_exception(GenAiClientError(400)).is_input_dependent is False
+
+
+class _NoRetryPolicyModel:
+ """Stand-in for AgentScope's Ollama embedding model: an empty policy plus a numeric budget.
+
+ The real model keeps the inherited ``max_retries=3`` next to the empty tuple, so the double
+ has to carry both fields: omitting the number is what let the previous helper mistake the
+ budget for a policy.
+ """
+
+ max_retries = 3
+
+ @classmethod
+ def _get_retryable_exceptions(cls):
+ """Mirror the inherited empty policy."""
+ return ()
+
+
+class _PartialRetryPolicyModel:
+ """Stand-in for AgentScope's DashScope embedding model: ``(RuntimeError,)`` and nothing else."""
+
+ max_retries = 3
+
+ @classmethod
+ def _get_retryable_exceptions(cls):
+ """Mirror the DashScope policy, which does not cover a connection error."""
+ return (RuntimeError,)
+
+
+class OllamaLikeTransientProvider(TransientThenSuccessAsEmbedding):
+ """An Ollama-shaped provider: the bound model declares no retry policy."""
+
+ def __init__(self, error: Exception, failures: int = 1):
+ super().__init__(error, failures)
+ self.model = _NoRetryPolicyModel()
+
+
+@pytest.mark.asyncio
+async def test_a_provider_with_an_empty_retry_policy_recovers_a_connection_error(monkeypatch):
+ """The store is the retry owner when the model below declares no policy.
+
+ Ollama's embedding model inherits an empty retryable tuple and its SDK converts
+ ``httpx.ConnectError`` into a plain ``ConnectionError`` without retrying, so leaving the
+ retry to the lower layers would turn one dropped connection into a lost vector. The base
+ implementation answered that case with two requests; so must this one.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ provider = OllamaLikeTransientProvider(ConnectionError("connection reset"))
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_empty_retry_policy",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["a", "b", "c"])).embeddings
+
+ assert results == [[1.0, 0.0]] * 3
+ assert provider.batch_sizes == [3, 3]
+ assert sleeps == [1.0]
+
+
+@pytest.mark.asyncio
+async def test_a_policy_that_does_not_cover_the_error_keeps_the_store_backoff(monkeypatch):
+ """A nonempty policy is not ownership unless it covers the error that actually occurred.
+
+ AgentScope's DashScope model declares ``(RuntimeError,)``, which does not cover a connection
+ error raised by ``requests`` underneath it. Trusting the whole tuple would leave that retry
+ to a layer that re-raises the error immediately, so one dropped connection would still cost
+ the vector. The store has to answer it as the base implementation did.
+ """
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+
+ provider = OllamaLikeTransientProvider(ConnectionError("connection reset"))
+ provider.model = _PartialRetryPolicyModel()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_partial_policy",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["a", "b", "c"])).embeddings
+
+ assert results == [[1.0, 0.0]] * 3
+ assert provider.batch_sizes == [3, 3]
+ assert sleeps == [1.0]
+
+
+@pytest.mark.asyncio
+async def test_an_isolated_item_does_not_multiply_sdk_transport_retries(monkeypatch):
+ """Isolating an item must not add a third retry loop over the SDK's budget.
+
+ A rejected batch costs one request; the first isolated item then hits a persistent timeout,
+ which the wrapper and the SDK already try 12 times. The store must stop there (13 requests
+ in total) instead of asking that item three more times (37), and the failure is provider
+ wide, so the remaining items are left unanswered rather than probed one by one.
+ """
+
+ def handler(request):
+ body = json.loads(request.content.decode("utf-8"))
+ inputs = body["input"]
+ if isinstance(inputs, str):
+ inputs = [inputs]
+ if any(len(item) > 32 for item in inputs):
+ return context_length_rejection()
+ return read_timeout(request)
+
+ endpoint = offline_openai_endpoint(handler).install(monkeypatch)
+ provider = openai_wrapper("t_openai_transport_isolated_budget")
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_sdk_isolated_budget",
+ max_retries=3,
+ enable_cache=False,
+ )
+ store.as_embedding = provider
+
+ results = (await store._call_with_retry(["ok", "x" * 64, "also ok"])).embeddings
+
+ assert results == [None, None, None]
+ assert provider.calls == 2
+ assert endpoint.requests == 13
+ assert store.is_healthy is False
+
+
+@pytest.mark.parametrize(
+ ("error", "batch_size", "attempts"),
+ [
+ pytest.param(AuthenticationError("invalid api key"), 10, 1, id="authentication"),
+ pytest.param(PermissionDeniedError("no access to model"), 10, 1, id="permission"),
+ pytest.param(InternalServerError("upstream unavailable"), 10, 1, id="server"),
+ pytest.param(NotFoundError("model_not_found"), 10, 1, id="not-found"),
+ pytest.param(GenAiClientError(401), 10, 1, id="genai-authentication"),
+ pytest.param(GenAiServerError(503), 10, 1, id="genai-server"),
+ pytest.param(GenAiClientError(429), 10, 3, id="genai-rate-limit"),
+ pytest.param(DashScopeRuntimeError.for_status(401), 10, 1, id="dashscope-authentication"),
+ pytest.param(DashScopeRuntimeError.for_status(503), 10, 1, id="dashscope-server"),
+ pytest.param(DashScopeRuntimeError.for_status(429), 10, 3, id="dashscope-rate-limit"),
+ pytest.param(RuntimeError("provider said no"), 3, 1, id="unrecognized"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_service_failures_preserve_the_batch_retry_budget(monkeypatch, error, batch_size, attempts):
+ """Keep every provider error shape and its retry budget without splitting inputs."""
+ sleeps = []
+
+ async def fake_sleep(delay):
+ sleeps.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+ provider = AlwaysProviderWideFailureAsEmbedding(error)
+ store = LocalEmbeddingStore(name="t_local_embedding_service_failure", max_retries=3, enable_cache=False)
+ store.as_embedding = provider
+
+ assert (await store._call_with_retry([f"item{i}" for i in range(batch_size)])).embeddings is None
+ assert provider.batch_sizes == [batch_size] * attempts
+ assert len(sleeps) == attempts - 1
+ assert store.is_healthy is False
+
+
+# -- Bounded input-shortening recovery for over-limit items --
+
+
+class ShrinkablePerItemLimitAsEmbedding:
+ """Reject any text longer than ``max_chars``, so only a shorter request can embed it.
+
+ Mirrors a provider that enforces a token window the store's character budget cannot see:
+ the same text stays rejected no matter how often it is resent, so the only way to keep
+ its vector is to send less of it.
+ """
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+
+ def __init__(self, max_chars: int):
+ self.max_chars = max_chars
+ self.batch_sizes: list[int] = []
+ self.embedded_texts: list[str] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ raise ContextLengthError("the input length exceeds the context length")
+ self.embedded_texts.extend(texts)
+ return [[1.0, 0.0] for _ in texts]
+
+
+class RateLimitedError(Exception):
+ """OpenAI-compatible 429 used to burn the retry budget before a length rejection."""
+
+ status_code = 429
+ code = "rate_limit_exceeded"
+
+
+class LateOverLimitAsEmbedding:
+ """Rate-limit the isolated long item, then reject it as too long on its final attempt."""
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+
+ def __init__(self, max_chars: int, single_call_limits: int = 2):
+ self.max_chars = max_chars
+ self.single_call_limits = single_call_limits
+ self.batch_sizes: list[int] = []
+ self.embedded_texts: list[str] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ if any(len(text) > self.max_chars for text in texts):
+ if len(texts) == 1 and self.single_call_limits:
+ self.single_call_limits -= 1
+ raise RateLimitedError("rate limit exceeded")
+ raise ContextLengthError("the input length exceeds the context length")
+ self.embedded_texts.extend(texts)
+ return [[1.0, 0.0] for _ in texts]
+
+
+class UnrelatedBadRequestError(Exception):
+ """A 400 that has nothing to do with the input length, e.g. a malformed parameter."""
+
+ status_code = 400
+
+
+class UnrelatedBadRequestAsEmbedding:
+ """Always fail with a 400 that does not name the input length."""
+
+ dimensions = 2
+ vector_space_id = "fakespace000"
+
+ def __init__(self):
+ self.batch_sizes: list[int] = []
+
+ def initialize_model(self):
+ """Mirror the real component's idempotent initialization hook."""
+
+ async def __call__(self, texts: list[str], **_kwargs):
+ self.batch_sizes.append(len(texts))
+ raise UnrelatedBadRequestError("unsupported encoding format")
+
+
+@pytest.mark.asyncio
+async def test_over_limit_item_is_recovered_by_a_shorter_request():
+ """A per-item rejection that survives isolation must be retried with less input."""
+ provider = ShrinkablePerItemLimitAsEmbedding(max_chars=40)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_recovery",
+ enable_cache=False,
+ max_input_length=80,
+ )
+ store.as_embedding = provider
+ long_text = "x" * 80
+
+ result = await store._call_with_retry(["ok", long_text, "also ok"])
+
+ assert result.embeddings == [[1.0, 0.0], [1.0, 0.0], [1.0, 0.0]]
+ assert result.terminal_failure is False
+ assert provider.batch_sizes == [3, 1, 1, 1, 1]
+ assert long_text not in provider.embedded_texts
+ assert provider.embedded_texts == ["ok", "x" * 40, "also ok"]
+
+
+@pytest.mark.asyncio
+async def test_shortened_retry_leaves_the_configured_budget_alone():
+ """Recovery is per request: the shared character budget must not shrink for everyone."""
+ provider = ShrinkablePerItemLimitAsEmbedding(max_chars=40)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_budget",
+ enable_cache=False,
+ max_input_length=80,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["x" * 80])
+
+ assert result.embeddings == [[1.0, 0.0]]
+ assert store.max_input_length == 80
+
+
+@pytest.mark.asyncio
+async def test_batch_rejected_for_one_item_keeps_every_vector():
+ """The end-to-end promise: an over-limit chunk no longer costs its batch mates or itself."""
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_nodes",
+ enable_cache=False,
+ max_input_length=80,
+ )
+ store.as_embedding = ShrinkablePerItemLimitAsEmbedding(max_chars=40)
+ nodes = [EmbNode(text="first"), EmbNode(text="y" * 80), EmbNode(text="third")]
+
+ await store.get_node_embeddings(nodes)
+
+ assert all(node.embedding is not None for node in nodes)
+
+
+@pytest.mark.asyncio
+async def test_input_length_retry_is_skipped_when_the_input_cannot_shrink():
+ """A rejection that a smaller budget cannot change must not be retried at all."""
+ provider = ShrinkablePerItemLimitAsEmbedding(max_chars=8)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_noop",
+ enable_cache=False,
+ max_input_length=8192,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["x" * 64])
+
+ assert result.embeddings is None
+ assert provider.batch_sizes == [1]
+
+
+@pytest.mark.asyncio
+async def test_shortening_needs_an_attempt_left_to_send():
+ """With no retry budget left the store gives up instead of resending a shorter input."""
+ provider = ShrinkablePerItemLimitAsEmbedding(max_chars=40)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_bounded",
+ enable_cache=False,
+ max_input_length=80,
+ max_retries=1,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["x" * 80])
+
+ assert result.embeddings is None
+ assert result.terminal_failure is False
+ assert provider.batch_sizes == [1]
+
+
+@pytest.mark.asyncio
+async def test_final_attempt_rejection_keeps_the_healthy_siblings():
+ """An input-length rejection on the last attempt must not stop the per-item isolation.
+
+ Shortening cannot be sent without an attempt left, so that request has to fall through to
+ the input-dependent path. Otherwise the single item's rejection is recorded as a terminal
+ batch failure and every item after it is never requested -- the reported regression.
+ """
+ provider = ShrinkablePerItemLimitAsEmbedding(max_chars=40)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_final_attempt",
+ enable_cache=False,
+ max_input_length=80,
+ max_retries=1,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["ok", "x" * 80, "after"])
+
+ assert result.embeddings == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert result.terminal_failure is False
+ assert provider.embedded_texts == ["ok", "after"]
+ assert provider.batch_sizes == [3, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_rate_limited_item_rejected_on_its_final_attempt_keeps_the_siblings():
+ """The same regression through rate limits: two 429s burn the budget before the rejection."""
+ provider = LateOverLimitAsEmbedding(max_chars=40, single_call_limits=2)
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_after_rate_limits",
+ enable_cache=False,
+ max_input_length=80,
+ max_retries=3,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["ok", "x" * 80, "after"])
+
+ assert result.embeddings == [[1.0, 0.0], None, [1.0, 0.0]]
+ assert result.terminal_failure is False
+ assert provider.embedded_texts == ["ok", "after"]
+ assert provider.batch_sizes == [3, 1, 1, 1, 1, 1]
+
+
+@pytest.mark.asyncio
+async def test_unrelated_bad_request_is_not_retried_with_a_shorter_input():
+ """Only a 400 that names the input length may be answered with less input."""
+ provider = UnrelatedBadRequestAsEmbedding()
+ store = LocalEmbeddingStore(
+ name="t_local_embedding_shrink_unrelated",
+ enable_cache=False,
+ max_input_length=80,
+ )
+ store.as_embedding = provider
+
+ result = await store._call_with_retry(["x" * 80])
+
+ assert result.embeddings is None
+ assert provider.batch_sizes == [1]
diff --git a/tests/unit/test_markdown_file_chunker.py b/tests/unit/test_markdown_file_chunker.py
index 62dec8d2..3b18601b 100644
--- a/tests/unit/test_markdown_file_chunker.py
+++ b/tests/unit/test_markdown_file_chunker.py
@@ -8,6 +8,7 @@ a markdown-to-FileNode transformer.
# pylint: disable=protected-access
+from contextlib import chdir as temp_chdir
import asyncio
import os
import tempfile
@@ -18,22 +19,6 @@ import pytest
from reme.components.file_chunker import DefaultFileChunker, MarkdownFileChunker
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def _write_md(tmpdir: str, name: str, body: str) -> str:
"""Drop a markdown file under tmpdir, return its relative path (matches cwd)."""
if "/" in name:
@@ -43,65 +28,58 @@ def _write_md(tmpdir: str, name: str, body: str) -> str:
return name
-def test_parse_empty_file():
+@pytest.mark.asyncio
+async def test_parse_empty_file():
"""An empty .md → FileNode, no chunks, no links."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- path = _write_md(tmp, "x.md", "")
- chunker = MarkdownFileChunker()
- node, chunks = await chunker.chunk(path)
- assert node.path == "x.md"
- assert chunks == []
- assert node.links == []
- print("✓ test_parse_empty_file passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "x.md", "")
+ chunker = MarkdownFileChunker()
+ node, chunks = await chunker.chunk(path)
+ assert node.path == "x.md"
+ assert chunks == []
+ assert node.links == []
-def test_invalid_utf8_is_replaced_without_modifying_source():
+@pytest.mark.asyncio
+async def test_invalid_utf8_is_replaced_without_modifying_source():
"""Bad source bytes degrade the derived index but remain untouched on disk."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- source = b"# valid\ncontent before \xcd content after\n"
- path = os.path.join(tmp, "invalid.md")
- with open(path, "wb") as f:
- f.write(source)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ source = b"# valid\ncontent before \xcd content after\n"
+ path = os.path.join(tmp, "invalid.md")
+ with open(path, "wb") as f:
+ f.write(source)
- chunker = MarkdownFileChunker()
- with patch.object(chunker.logger, "warning") as warning:
- node, chunks = await chunker.chunk("invalid.md")
+ chunker = MarkdownFileChunker()
+ with patch.object(chunker.logger, "warning") as warning:
+ node, chunks = await chunker.chunk("invalid.md")
- assert node.path == "invalid.md"
- assert "content before \ufffd content after" in "\n".join(chunk.text for chunk in chunks)
- with open(path, "rb") as f:
- assert f.read() == source
- warning.assert_called_once_with(
- "Invalid utf-8 in invalid.md at byte 23 (bytes: cd); "
- "indexed with replacement characters; source file unchanged",
- )
-
- asyncio.run(run())
+ assert node.path == "invalid.md"
+ assert "content before \ufffd content after" in "\n".join(chunk.text for chunk in chunks)
+ with open(path, "rb") as f:
+ assert f.read() == source
+ warning.assert_called_once_with(
+ "Invalid utf-8 in invalid.md at byte 23 (bytes: cd); "
+ "indexed with replacement characters; source file unchanged",
+ )
-def test_invalid_utf8_strict_policy_still_raises():
+@pytest.mark.asyncio
+async def test_invalid_utf8_strict_policy_still_raises():
"""Strict mode remains available when callers require exact decoding."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- with open(os.path.join(tmp, "invalid.md"), "wb") as f:
- f.write(b"valid\xcdinvalid")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ with open(os.path.join(tmp, "invalid.md"), "wb") as f:
+ f.write(b"valid\xcdinvalid")
- chunker = MarkdownFileChunker(invalid_encoding_policy="strict")
- try:
- await chunker.chunk("invalid.md")
- except UnicodeDecodeError as exc:
- assert exc.start == 5
- else:
- raise AssertionError("strict policy must reject invalid UTF-8")
-
- asyncio.run(run())
+ chunker = MarkdownFileChunker(invalid_encoding_policy="strict")
+ try:
+ await chunker.chunk("invalid.md")
+ except UnicodeDecodeError as exc:
+ assert exc.start == 5
+ else:
+ raise AssertionError("strict policy must reject invalid UTF-8")
def test_constructor_preserves_positional_arguments():
@@ -116,61 +94,54 @@ def test_constructor_preserves_positional_arguments():
assert chunker.include_frontmatter_keys_in_metadata == ["name"]
-def test_plain_text_fallback_normalizes_newlines():
+@pytest.mark.asyncio
+async def test_plain_text_fallback_normalizes_newlines():
"""Markdown fallback chunks stay stable for equivalent platform newlines."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- path = os.path.join(tmp, "fallback.md")
- with open(path, "wb") as f:
- f.write(b"# One\r\nbody\r# Two\r\nbody\r\n")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = os.path.join(tmp, "fallback.md")
+ with open(path, "wb") as f:
+ f.write(b"# One\r\nbody\r# Two\r\nbody\r\n")
- chunker = MarkdownFileChunker(max_ast_sections=0)
- _, original_chunks = await chunker.chunk("fallback.md")
- with open(path, "wb") as f:
- f.write(b"# One\nbody\n# Two\nbody\n")
- _, normalized_chunks = await chunker.chunk("fallback.md")
+ chunker = MarkdownFileChunker(max_ast_sections=0)
+ _, original_chunks = await chunker.chunk("fallback.md")
+ with open(path, "wb") as f:
+ f.write(b"# One\nbody\n# Two\nbody\n")
+ _, normalized_chunks = await chunker.chunk("fallback.md")
- assert [chunk.text for chunk in original_chunks] == [chunk.text for chunk in normalized_chunks]
- assert [chunk.id for chunk in original_chunks] == [chunk.id for chunk in normalized_chunks]
-
- asyncio.run(run())
+ assert [chunk.text for chunk in original_chunks] == [chunk.text for chunk in normalized_chunks]
+ assert [chunk.id for chunk in original_chunks] == [chunk.id for chunk in normalized_chunks]
-def test_invalid_ascii_is_replaced_for_markdown():
+@pytest.mark.asyncio
+async def test_invalid_ascii_is_replaced_for_markdown():
"""Markdown byte accounting accepts the configured codec's replacement text."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- source = b"# valid\ncontent before \xff content after\n"
- path = os.path.join(tmp, "invalid.md")
- with open(path, "wb") as f:
- f.write(source)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ source = b"# valid\ncontent before \xff content after\n"
+ path = os.path.join(tmp, "invalid.md")
+ with open(path, "wb") as f:
+ f.write(source)
- chunker = MarkdownFileChunker(encoding="ascii")
- _, chunks = await chunker.chunk("invalid.md")
+ chunker = MarkdownFileChunker(encoding="ascii")
+ _, chunks = await chunker.chunk("invalid.md")
- assert "content before ? content after" in "\n".join(chunk.text for chunk in chunks)
- with open(path, "rb") as f:
- assert f.read() == source
-
- asyncio.run(run())
+ assert "content before ? content after" in "\n".join(chunk.text for chunk in chunks)
+ with open(path, "rb") as f:
+ assert f.read() == source
-def test_parse_frontmatter_only():
+@pytest.mark.asyncio
+async def test_parse_frontmatter_only():
"""A file with only frontmatter (no body) → no chunks, no links."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- path = _write_md(tmp, "fm.md", "---\nname: t\n---\n")
- chunker = MarkdownFileChunker()
- node, chunks = await chunker.chunk(path)
- assert node.front_matter.name == "t"
- assert chunks == []
- assert node.links == []
- print("✓ test_parse_frontmatter_only passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "fm.md", "---\nname: t\n---\n")
+ chunker = MarkdownFileChunker()
+ node, chunks = await chunker.chunk(path)
+ assert node.front_matter.name == "t"
+ assert chunks == []
+ assert node.links == []
@pytest.mark.parametrize("max_ast_sections", [0, None])
@@ -193,229 +164,194 @@ def test_markdown_source_lines_and_metadata_remain_separate(tmp_path, max_ast_se
assert path.read_bytes() == source
-def test_parse_frontmatter_metadata_is_opt_in():
+@pytest.mark.asyncio
+async def test_parse_frontmatter_metadata_is_opt_in():
"""Chunk metadata preserves the old empty default unless explicitly enabled."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = (
- "---\n"
- "name: locomo-event\n"
- "description: Jon lost his job\n"
- "conversation_date: 2023-01-19\n"
- "---\n"
- "Jon said he lost his job today.\n"
- )
- path = _write_md(tmp, "daily/2023-01-19/locomo-event.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=500)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = (
+ "---\n"
+ "name: locomo-event\n"
+ "description: Jon lost his job\n"
+ "conversation_date: 2023-01-19\n"
+ "---\n"
+ "Jon said he lost his job today.\n"
+ )
+ path = _write_md(tmp, "daily/2023-01-19/locomo-event.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=500)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) == 1
- assert chunks[0].metadata == {}
+ assert len(chunks) == 1
+ assert chunks[0].metadata == {}
- chunker = MarkdownFileChunker(chunk_byte_size=500, include_frontmatter_in_metadata=True)
- _, chunks = await chunker.chunk(path)
+ chunker = MarkdownFileChunker(chunk_byte_size=500, include_frontmatter_in_metadata=True)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) == 1
- assert chunks[0].metadata == {
- "name": "locomo-event",
- "description": "Jon lost his job",
- "conversation_date": "2023-01-19",
- }
- print("✓ test_parse_frontmatter_metadata_is_opt_in passed")
-
- asyncio.run(run())
+ assert len(chunks) == 1
+ assert chunks[0].metadata == {
+ "name": "locomo-event",
+ "description": "Jon lost his job",
+ "conversation_date": "2023-01-19",
+ }
-def test_parse_frontmatter_metadata_keys_allowlist():
+@pytest.mark.asyncio
+async def test_parse_frontmatter_metadata_keys_allowlist():
"""``include_frontmatter_keys_in_metadata`` restricts copied keys to an allow-list."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = (
- "---\n"
- "name: locomo-event\n"
- "description: Jon lost his job\n"
- "conversation_date: 2023-01-19\n"
- "---\n"
- "Jon said he lost his job today.\n"
- )
- path = _write_md(tmp, "daily/2023-01-19/locomo-event.md", body)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = (
+ "---\n"
+ "name: locomo-event\n"
+ "description: Jon lost his job\n"
+ "conversation_date: 2023-01-19\n"
+ "---\n"
+ "Jon said he lost his job today.\n"
+ )
+ path = _write_md(tmp, "daily/2023-01-19/locomo-event.md", body)
- # Allow-list restricted to a single key.
- chunker = MarkdownFileChunker(
- chunk_byte_size=500,
- include_frontmatter_in_metadata=True,
- include_frontmatter_keys_in_metadata=["conversation_date"],
- )
- _, chunks = await chunker.chunk(path)
- assert len(chunks) == 1
- assert chunks[0].metadata == {"conversation_date": "2023-01-19"}
+ # Allow-list restricted to a single key.
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=500,
+ include_frontmatter_in_metadata=True,
+ include_frontmatter_keys_in_metadata=["conversation_date"],
+ )
+ _, chunks = await chunker.chunk(path)
+ assert len(chunks) == 1
+ assert chunks[0].metadata == {"conversation_date": "2023-01-19"}
- # Allow-list with a key not present in frontmatter is a no-op for that key.
- chunker = MarkdownFileChunker(
- chunk_byte_size=500,
- include_frontmatter_in_metadata=True,
- include_frontmatter_keys_in_metadata=["conversation_date", "absent"],
- )
- _, chunks = await chunker.chunk(path)
- assert chunks[0].metadata == {"conversation_date": "2023-01-19"}
+ # Allow-list with a key not present in frontmatter is a no-op for that key.
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=500,
+ include_frontmatter_in_metadata=True,
+ include_frontmatter_keys_in_metadata=["conversation_date", "absent"],
+ )
+ _, chunks = await chunker.chunk(path)
+ assert chunks[0].metadata == {"conversation_date": "2023-01-19"}
- # Empty allow-list (not None) keeps the legacy "all non-empty keys" behavior.
- chunker = MarkdownFileChunker(
- chunk_byte_size=500,
- include_frontmatter_in_metadata=True,
- include_frontmatter_keys_in_metadata=[],
- )
- _, chunks = await chunker.chunk(path)
- assert chunks[0].metadata == {
- "name": "locomo-event",
- "description": "Jon lost his job",
- "conversation_date": "2023-01-19",
- }
+ # Empty allow-list (not None) keeps the legacy "all non-empty keys" behavior.
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=500,
+ include_frontmatter_in_metadata=True,
+ include_frontmatter_keys_in_metadata=[],
+ )
+ _, chunks = await chunker.chunk(path)
+ assert chunks[0].metadata == {
+ "name": "locomo-event",
+ "description": "Jon lost his job",
+ "conversation_date": "2023-01-19",
+ }
- # Allow-list is ignored when the master toggle is off (back-compat default).
- chunker = MarkdownFileChunker(
- chunk_byte_size=500,
- include_frontmatter_in_metadata=False,
- include_frontmatter_keys_in_metadata=["conversation_date"],
- )
- _, chunks = await chunker.chunk(path)
- assert chunks[0].metadata == {}
- print("✓ test_parse_frontmatter_metadata_keys_allowlist passed")
-
- asyncio.run(run())
+ # Allow-list is ignored when the master toggle is off (back-compat default).
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=500,
+ include_frontmatter_in_metadata=False,
+ include_frontmatter_keys_in_metadata=["conversation_date"],
+ )
+ _, chunks = await chunker.chunk(path)
+ assert chunks[0].metadata == {}
-def test_parse_small_body_one_chunk():
+@pytest.mark.asyncio
+async def test_parse_small_body_one_chunk():
"""A body shorter than chunk_byte_size produces exactly one chunk that contains the body."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "# Hello\n\nthis is a small body."
- path = _write_md(tmp, "small.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=500)
- node, chunks = await chunker.chunk(path)
- assert len(chunks) == 1
- assert "this is a small body" in chunks[0].text
- assert node.chunk_ids == [chunks[0].id]
- print("✓ test_parse_small_body_one_chunk passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "# Hello\n\nthis is a small body."
+ path = _write_md(tmp, "small.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=500)
+ node, chunks = await chunker.chunk(path)
+ assert len(chunks) == 1
+ assert "this is a small body" in chunks[0].text
+ assert node.chunk_ids == [chunks[0].id]
-def test_parse_small_children_are_cached_without_recursive_calls():
+@pytest.mark.asyncio
+async def test_parse_small_children_are_cached_without_recursive_calls():
"""An oversized parent greedily caches fitting children without recursing into them."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- sections = "\n\n".join(f"# Section-{i}\n\n{'x' * 30}" for i in range(6))
- path = _write_md(tmp, "small-children.md", sections)
- chunker = MarkdownFileChunker(chunk_byte_size=100, embed_toc=False)
- with patch.object(
- chunker,
- "_chunk_node",
- wraps=chunker._chunk_node,
- ) as chunk_node:
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ sections = "\n\n".join(f"# Section-{i}\n\n{'x' * 30}" for i in range(6))
+ path = _write_md(tmp, "small-children.md", sections)
+ chunker = MarkdownFileChunker(chunk_byte_size=100, embed_toc=False)
+ with patch.object(
+ chunker,
+ "_chunk_node",
+ wraps=chunker._chunk_node,
+ ) as chunk_node:
+ _, chunks = await chunker.chunk(path)
- assert chunk_node.call_count == 1
- assert 1 < len(chunks) < 6
- assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
-
- asyncio.run(run())
+ assert chunk_node.call_count == 1
+ assert 1 < len(chunks) < 6
+ assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
-def test_parse_child_cache_flushes_at_exact_limit():
+@pytest.mark.asyncio
+async def test_parse_child_cache_flushes_at_exact_limit():
"""A cache reaching ``chunk_byte_size`` is finalized before the next child."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- path = _write_md(tmp, "exact-cache.md", f"# A\n\n{'x' * 95}\n\n# B\n\ny")
- chunker = MarkdownFileChunker(chunk_byte_size=100)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "exact-cache.md", f"# A\n\n{'x' * 95}\n\n# B\n\ny")
+ chunker = MarkdownFileChunker(chunk_byte_size=100)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) == 2
- assert len(chunks[0].text) == chunker.chunk_byte_size
- assert chunks[0].text.startswith("# A")
- assert chunks[1].text == "# B\n\ny"
-
- asyncio.run(run())
+ assert len(chunks) == 2
+ assert len(chunks[0].text) == chunker.chunk_byte_size
+ assert chunks[0].text.startswith("# A")
+ assert chunks[1].text == "# B\n\ny"
-def test_parse_oversized_child_flushes_parent_cache():
+@pytest.mark.asyncio
+async def test_parse_oversized_child_flushes_parent_cache():
"""Recursive child chunks do not merge across the parent cache boundary."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- leaves = "\n\n".join(f"## L{i}\n\n{'x' * 10}" for i in range(6))
- body = f"# A\n\na\n\n# Large\n\n{leaves}\n\n# C\n\nc"
- path = _write_md(tmp, "recursive-boundary.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=100, embed_toc=False)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ leaves = "\n\n".join(f"## L{i}\n\n{'x' * 10}" for i in range(6))
+ body = f"# A\n\na\n\n# Large\n\n{leaves}\n\n# C\n\nc"
+ path = _write_md(tmp, "recursive-boundary.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=100, embed_toc=False)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) == 4
- assert chunks[0].text == "# A\n\na"
- assert chunks[-2].text == f"## L4\n\n{'x' * 10}\n\n## L5\n\n{'x' * 10}"
- assert chunks[-1].text == "# C\n\nc"
-
- asyncio.run(run())
+ assert len(chunks) == 4
+ assert chunks[0].text == "# A\n\na"
+ assert chunks[-2].text == f"## L4\n\n{'x' * 10}\n\n## L5\n\n{'x' * 10}"
+ assert chunks[-1].text == "# C\n\nc"
-def test_parse_oversized_body_splits():
+@pytest.mark.asyncio
+async def test_parse_oversized_body_splits():
"""A body exceeding chunk_byte_size triggers multiple chunks."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- paras = "\n\n".join(f"paragraph {i} with some content text here." for i in range(50))
- body = "# H\n\n" + paras
- path = _write_md(tmp, "big.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=200)
- _, chunks = await chunker.chunk(path)
- assert len(chunks) > 1
- assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
- print("✓ test_parse_oversized_body_splits passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ paras = "\n\n".join(f"paragraph {i} with some content text here." for i in range(50))
+ body = "# H\n\n" + paras
+ path = _write_md(tmp, "big.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=200)
+ node, chunks = await chunker.chunk(path)
+ assert len(chunks) > 1
+ assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
+ assert node.chunk_ids == [chunk.id for chunk in chunks]
-def test_parse_chunk_ids_match_node_chunk_ids():
- """node.chunk_ids is the ordered list of chunk hashes."""
-
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- paras = "\n\n".join(f"para {i} body content here." for i in range(40))
- body = "# H\n\n" + paras
- path = _write_md(tmp, "p.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=200)
- node, chunks = await chunker.chunk(path)
- assert node.chunk_ids == [c.id for c in chunks]
- print("✓ test_parse_chunk_ids_match_node_chunk_ids passed")
-
- asyncio.run(run())
-
-
-def test_parse_links_literal_targets():
+@pytest.mark.asyncio
+async def test_parse_links_literal_targets():
"""Wikilink targets are taken verbatim — full path → FileLink.target_path."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "see [[topics/Alice.md]] and [[topics/Bob.md#sec]]"
- path = _write_md(tmp, "note.md", body)
- chunker = MarkdownFileChunker()
- node, _ = await chunker.chunk(path)
- pairs = {(link.target_path, link.target_anchor) for link in node.links}
- assert ("topics/Alice.md", None) in pairs
- assert ("topics/Bob.md", "sec") in pairs
- # source_path always equals the node's own path
- for link in node.links:
- assert link.source_path == node.path
- print("✓ test_parse_links_literal_targets passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "see [[topics/Alice.md]] and [[topics/Bob.md#sec]]"
+ path = _write_md(tmp, "note.md", body)
+ chunker = MarkdownFileChunker()
+ node, _ = await chunker.chunk(path)
+ pairs = {(link.target_path, link.target_anchor) for link in node.links}
+ assert ("topics/Alice.md", None) in pairs
+ assert ("topics/Bob.md", "sec") in pairs
+ # source_path always equals the node's own path
+ for link in node.links:
+ assert link.source_path == node.path
-def test_parse_links_short_and_no_ext_kept_literally():
+@pytest.mark.asyncio
+async def test_parse_links_short_and_no_ext_kept_literally():
"""Short and no-ext forms are NOT resolved — they're stored as-is.
The parser does no resolution; whether the target exists is a
@@ -424,70 +360,56 @@ def test_parse_links_short_and_no_ext_kept_literally():
with literal path 'Alice' actually exists.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "see [[Alice]] and [[topics/Alice]] but also [[topics/Alice.md]]"
- path = _write_md(tmp, "note.md", body)
- chunker = MarkdownFileChunker()
- node, _ = await chunker.chunk(path)
- targets = {link.target_path for link in node.links}
- assert targets == {"Alice", "topics/Alice", "topics/Alice.md"}
- print("✓ test_parse_links_short_and_no_ext_kept_literally passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "see [[Alice]] and [[topics/Alice]] but also [[topics/Alice.md]]"
+ path = _write_md(tmp, "note.md", body)
+ chunker = MarkdownFileChunker()
+ node, _ = await chunker.chunk(path)
+ targets = {link.target_path for link in node.links}
+ assert targets == {"Alice", "topics/Alice", "topics/Alice.md"}
-def test_parse_links_legacy_relation_text_is_ignored():
+@pytest.mark.asyncio
+async def test_parse_links_legacy_relation_text_is_ignored():
"""Old relation wrappers do not prevent their inner wikilinks from parsing."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "extends:: [[A.md]]\n\nsome [concerns:: [[B.md]]] inline\n"
- path = _write_md(tmp, "note.md", body)
- chunker = MarkdownFileChunker()
- node, _ = await chunker.chunk(path)
- assert {link.target_path for link in node.links} == {"A.md", "B.md"}
- print("✓ test_parse_links_legacy_relation_text_is_ignored passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "extends:: [[A.md]]\n\nsome [concerns:: [[B.md]]] inline\n"
+ path = _write_md(tmp, "note.md", body)
+ chunker = MarkdownFileChunker()
+ node, _ = await chunker.chunk(path)
+ assert {link.target_path for link in node.links} == {"A.md", "B.md"}
-def test_parse_links_deduped():
+@pytest.mark.asyncio
+async def test_parse_links_deduped():
"""Repeated wikilinks with the same target and anchor emit one FileLink."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "[[A.md]] again [[A.md]] and [[A.md]]"
- path = _write_md(tmp, "note.md", body)
- chunker = MarkdownFileChunker()
- node, _ = await chunker.chunk(path)
- assert len([link for link in node.links if link.target_path == "A.md"]) == 1
- print("✓ test_parse_links_deduped passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "[[A.md]] again [[A.md]] and [[A.md]]"
+ path = _write_md(tmp, "note.md", body)
+ chunker = MarkdownFileChunker()
+ node, _ = await chunker.chunk(path)
+ assert len([link for link in node.links if link.target_path == "A.md"]) == 1
def test_parse_min_chunk_byte_size_clamped():
"""chunk_byte_size below 100 should be clamped to 100."""
chunker = MarkdownFileChunker(chunk_byte_size=10)
assert chunker.chunk_byte_size == 100
- print("✓ test_parse_min_chunk_byte_size_clamped passed")
-def test_parse_embed_toc_prefixes_chunk_text():
+@pytest.mark.asyncio
+async def test_parse_embed_toc_prefixes_chunk_text():
"""When embed_toc=True, chunks emitted inside a section are prefixed by the heading."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "# Top\n\n## Sub\n\nbody-content"
- path = _write_md(tmp, "toc.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=True)
- _, chunks = await chunker.chunk(path)
- # Single small section fits; check that the heading appears in text.
- assert any("Top" in c.text for c in chunks)
- print("✓ test_parse_embed_toc_prefixes_chunk_text passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "# Top\n\n## Sub\n\nbody-content"
+ path = _write_md(tmp, "toc.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=True)
+ _, chunks = await chunker.chunk(path)
+ # Single small section fits; check that the heading appears in text.
+ assert any("Top" in c.text for c in chunks)
def test_parse_embed_toc_is_enabled_by_default():
@@ -504,335 +426,271 @@ def test_count_sections_ignores_fenced_headings_and_supports_setext():
assert MarkdownFileChunker._count_sections(content, stop_after=1) == 2
-def test_parse_excessive_sections_uses_plain_text_without_ast():
+@pytest.mark.asyncio
+async def test_parse_excessive_sections_uses_plain_text_without_ast():
"""Inherited fallback is standalone, bounded, and bypasses mistletoe."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- sections = "\n\n".join(f"# Section-{i}\n\n{'x' * 480} [[target.md]]" for i in range(101))
- body = f"---\nname: fallback\n---\n{sections}"
- path = _write_md(tmp, "fallback.md", body)
- chunker = MarkdownFileChunker(
- chunk_byte_size=10000,
- embed_toc=True,
- max_ast_sections=100,
- include_frontmatter_in_metadata=True,
- )
- await chunker.start()
- try:
- with (
- patch(
- "mistletoe.block_token.Document",
- side_effect=AssertionError("fallback must not construct an AST"),
- ),
- patch.object(
- chunker,
- "chunk_content",
- wraps=chunker.chunk_content,
- ) as chunk_content,
- ):
- node, chunks = await chunker.chunk(path)
- chunk_content.assert_called_once_with(sections, path, parse_links=True)
- finally:
- await chunker.close()
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ sections = "\n\n".join(f"# Section-{i}\n\n{'x' * 480} [[target.md]]" for i in range(101))
+ body = f"---\nname: fallback\n---\n{sections}"
+ path = _write_md(tmp, "fallback.md", body)
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=10000,
+ embed_toc=True,
+ max_ast_sections=100,
+ include_frontmatter_in_metadata=True,
+ )
+ await chunker.start()
+ try:
+ with (
+ patch(
+ "mistletoe.block_token.Document",
+ side_effect=AssertionError("fallback must not construct an AST"),
+ ),
+ patch.object(
+ chunker,
+ "chunk_content",
+ wraps=chunker.chunk_content,
+ ) as chunk_content,
+ ):
+ node, chunks = await chunker.chunk(path)
+ chunk_content.assert_called_once_with(sections, path, parse_links=True)
+ finally:
+ await chunker.close()
- assert isinstance(chunker, DefaultFileChunker)
- assert len(chunks) > 1
- assert max(len(chunk.text.encode("utf-8")) for chunk in chunks) <= chunker.chunk_byte_size
- assert chunks[0].start_line == 4
- assert node.front_matter.name == "fallback"
- assert node.chunk_ids == [chunk.id for chunk in chunks]
- assert {(link.target_path, link.source_path) for link in node.links} == {
- ("target.md", "fallback.md"),
- }
- assert all(chunk.metadata == {"name": "fallback"} for chunk in chunks)
- for i in (0, 100):
- assert any(f"# Section-{i}" in chunk.text for chunk in chunks)
-
- asyncio.run(run())
+ assert isinstance(chunker, DefaultFileChunker)
+ assert len(chunks) > 1
+ assert max(len(chunk.text.encode("utf-8")) for chunk in chunks) <= chunker.chunk_byte_size
+ assert chunks[0].start_line == 4
+ assert node.front_matter.name == "fallback"
+ assert node.chunk_ids == [chunk.id for chunk in chunks]
+ assert {(link.target_path, link.source_path) for link in node.links} == {
+ ("target.md", "fallback.md"),
+ }
+ assert all(chunk.metadata == {"name": "fallback"} for chunk in chunks)
+ for i in (0, 100):
+ assert any(f"# Section-{i}" in chunk.text for chunk in chunks)
-def test_parse_section_limit_is_inclusive_for_ast():
+@pytest.mark.asyncio
+async def test_parse_section_limit_is_inclusive_for_ast():
"""A document at the configured section limit still takes the AST path."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- path = _write_md(tmp, "at-limit.md", "# A\n\na\n\n# B\n\nb")
- chunker = MarkdownFileChunker(max_ast_sections=2)
- with patch.object(chunker, "_build_tree", wraps=chunker._build_tree) as build_tree:
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "at-limit.md", "# A\n\na\n\n# B\n\nb")
+ chunker = MarkdownFileChunker(max_ast_sections=2)
+ with patch.object(chunker, "_build_tree", wraps=chunker._build_tree) as build_tree:
+ _, chunks = await chunker.chunk(path)
- assert build_tree.call_count == 1
- assert len(chunks) == 1
-
- asyncio.run(run())
+ assert build_tree.call_count == 1
+ assert len(chunks) == 1
-def test_parse_small_sections_are_merged_and_headings_preserved():
+@pytest.mark.asyncio
+async def test_parse_small_sections_are_merged_and_headings_preserved():
"""Adjacent small sections share chunks without losing their headings."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- sections = "\n\n".join(f"## Section-{i:03d}\n\nfact-{i:03d}" for i in range(40))
- path = _write_md(tmp, "sections.md", f"# Root\n\n{sections}")
- chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=False)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ sections = "\n\n".join(f"## Section-{i:03d}\n\nfact-{i:03d}" for i in range(40))
+ path = _write_md(tmp, "sections.md", f"# Root\n\n{sections}")
+ chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=False)
+ _, chunks = await chunker.chunk(path)
- assert 1 < len(chunks) < 40
- for i in range(40):
- heading = f"## Section-{i:03d}"
- assert sum(chunk.text.count(heading) for chunk in chunks) == 1
-
- asyncio.run(run())
+ assert 1 < len(chunks) < 40
+ for i in range(40):
+ heading = f"## Section-{i:03d}"
+ assert sum(chunk.text.count(heading) for chunk in chunks) == 1
-def test_parse_embed_toc_uses_breadcrumbs_without_sibling_duplication():
+@pytest.mark.asyncio
+async def test_parse_embed_toc_uses_breadcrumbs_without_sibling_duplication():
"""TOC context repeats ancestors, not parallel section headings."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- sections = "\n\n".join(f"## Parallel-{i:03d}\n\nfact-{i:03d}" for i in range(40))
- path = _write_md(tmp, "breadcrumbs.md", f"# Root\n\n{sections}")
- chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=True)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ sections = "\n\n".join(f"## Parallel-{i:03d}\n\nfact-{i:03d}" for i in range(40))
+ path = _write_md(tmp, "breadcrumbs.md", f"# Root\n\n{sections}")
+ chunker = MarkdownFileChunker(chunk_byte_size=200, embed_toc=True)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) > 1
- assert all("# Root" in chunk.text for chunk in chunks)
- for i in range(40):
- heading = f"## Parallel-{i:03d}"
- assert sum(chunk.text.count(heading) for chunk in chunks) == 1
-
- asyncio.run(run())
+ assert len(chunks) > 1
+ assert all("# Root" in chunk.text for chunk in chunks)
+ for i in range(40):
+ heading = f"## Parallel-{i:03d}"
+ assert sum(chunk.text.count(heading) for chunk in chunks) == 1
-def test_parse_heading_heavy_output_grows_linearly():
+@pytest.mark.asyncio
+async def test_parse_heading_heavy_output_grows_linearly():
"""A thousand parallel sections remain a small, linear number of chunks."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- sections = "\n\n".join(f"## Observation-{i:04d}\n\nsynthetic fact" for i in range(1000))
- body = f"# Root\n\n{sections}"
- path = _write_md(tmp, "linear.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=10000, embed_toc=True, max_ast_sections=None)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ sections = "\n\n".join(f"## Observation-{i:04d}\n\nsynthetic fact" for i in range(1000))
+ body = f"# Root\n\n{sections}"
+ path = _write_md(tmp, "linear.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=10000, embed_toc=True, max_ast_sections=None)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) < 10
- assert sum(len(chunk.text) for chunk in chunks) < 2 * len(body)
-
- asyncio.run(run())
+ assert len(chunks) < 10
+ assert sum(len(chunk.text) for chunk in chunks) < 2 * len(body)
-def test_parse_nested_sections_merge_within_recursive_context():
+@pytest.mark.asyncio
+async def test_parse_nested_sections_merge_within_recursive_context():
"""Nested continuations retain their branch breadcrumb after recursive merging."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- branches = []
- for branch, fact in (("A", "a"), ("B", "b")):
- leaves = "\n\n".join(f"### {branch}{i}\n\n{fact * 45}" for i in range(4))
- branches.append(f"## {branch}\n\n{leaves}")
- branch_text = "\n\n".join(branches)
- path = _write_md(tmp, "nested.md", f"# Root\n\n{branch_text}")
- chunker = MarkdownFileChunker(chunk_byte_size=150, embed_toc=True)
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ branches = []
+ for branch, fact in (("A", "a"), ("B", "b")):
+ leaves = "\n\n".join(f"### {branch}{i}\n\n{fact * 45}" for i in range(4))
+ branches.append(f"## {branch}\n\n{leaves}")
+ branch_text = "\n\n".join(branches)
+ path = _write_md(tmp, "nested.md", f"# Root\n\n{branch_text}")
+ chunker = MarkdownFileChunker(chunk_byte_size=150, embed_toc=True)
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) == 5
- assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
- assert chunks[0].text == "# Root"
- assert chunks[2].text.startswith("# Root\n\n## A\n\n### A2")
- assert chunks[3].text.startswith("# Root\n\n## B\n\n### B0")
- assert "## B" not in chunks[2].text
-
- asyncio.run(run())
+ assert len(chunks) == 5
+ assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
+ assert chunks[0].text == "# Root"
+ assert chunks[2].text.startswith("# Root\n\n## A\n\n### A2")
+ assert chunks[3].text.startswith("# Root\n\n## B\n\n### B0")
+ assert "## B" not in chunks[2].text
-def test_parse_breadcrumbs_and_part_markers_share_byte_budget():
+@pytest.mark.asyncio
+async def test_parse_breadcrumbs_and_part_markers_share_byte_budget():
"""Breadcrumbs and part labels cannot push multi-byte chunks over budget."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- content_lines = "\n".join(f"内容-{i}-" * 6 for i in range(4))
- body = f"# {'根' * 15}\n\n## {'枝' * 15}\n\n### {'叶' * 15}\n\n{content_lines}"
- path = _write_md(tmp, "breadcrumb-budget.md", body)
- chunker = MarkdownFileChunker(
- chunk_byte_size=100,
- embed_toc=True,
- max_ast_sections=None,
- )
- _, chunks = await chunker.chunk(path)
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ content_lines = "\n".join(f"内容-{i}-" * 6 for i in range(4))
+ body = f"# {'根' * 15}\n\n## {'枝' * 15}\n\n### {'叶' * 15}\n\n{content_lines}"
+ path = _write_md(tmp, "breadcrumb-budget.md", body)
+ chunker = MarkdownFileChunker(
+ chunk_byte_size=100,
+ embed_toc=True,
+ max_ast_sections=None,
+ )
+ _, chunks = await chunker.chunk(path)
- assert len(chunks) > 1
- assert any(chunk.text.startswith("[Part ") for chunk in chunks)
- assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
-
- asyncio.run(run())
+ assert len(chunks) > 1
+ assert any(chunk.text.startswith("[Part ") for chunk in chunks)
+ assert all(len(chunk.text.encode("utf-8")) <= chunker.chunk_byte_size for chunk in chunks)
-def test_parse_frontmatter_preserves_original_line_numbers():
+@pytest.mark.asyncio
+async def test_parse_frontmatter_preserves_original_line_numbers():
"""Chunk line ranges are 1-based and refer to the original file, including frontmatter."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "---\nname: t\n---\n# H\nline 1\nline 2\n"
- path = _write_md(tmp, "front-lines.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=500)
- _, chunks = await chunker.chunk(path)
- assert len(chunks) == 1
- assert chunks[0].start_line == 4
- assert chunks[0].end_line == 6
- print("✓ test_parse_frontmatter_preserves_original_line_numbers passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "---\nname: t\n---\n# H\nline 1\nline 2\n"
+ path = _write_md(tmp, "front-lines.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=500)
+ _, chunks = await chunker.chunk(path)
+ assert len(chunks) == 1
+ assert chunks[0].start_line == 4
+ assert chunks[0].end_line == 6
-def test_parse_frontmatter_offsets_split_table_rows():
+@pytest.mark.asyncio
+async def test_parse_frontmatter_offsets_split_table_rows():
"""Split table row ranges include the YAML frontmatter line offset."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- rows = "".join(f"| {i} | {i} |\n" for i in range(12))
- body = "---\nname: t\n---\n| A | B |\n|---|---|\n" + rows
- path = _write_md(tmp, "front-table.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=100)
- _, chunks = await chunker.chunk(path)
- assert len(chunks) > 1
- assert chunks[0].start_line == 6
- assert chunks[0].end_line >= chunks[0].start_line
- print("✓ test_parse_frontmatter_offsets_split_table_rows passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ rows = "".join(f"| {i} | {i} |\n" for i in range(12))
+ body = "---\nname: t\n---\n| A | B |\n|---|---|\n" + rows
+ path = _write_md(tmp, "front-table.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=100)
+ _, chunks = await chunker.chunk(path)
+ assert len(chunks) > 1
+ assert chunks[0].start_line == 6
+ assert chunks[0].end_line >= chunks[0].start_line
-def test_parse_bad_frontmatter_does_not_abort_chunking():
+@pytest.mark.asyncio
+async def test_parse_bad_frontmatter_does_not_abort_chunking():
"""Invalid YAML frontmatter is ignored while the markdown body still chunks."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- body = "---\nname: [\n---\n# H\nbody\n"
- path = _write_md(tmp, "bad-frontmatter.md", body)
- chunker = MarkdownFileChunker(chunk_byte_size=500)
- node, chunks = await chunker.chunk(path)
- assert node.front_matter.name == ""
- assert len(chunks) == 1
- assert chunks[0].start_line == 4
- assert "body" in chunks[0].text
- print("✓ test_parse_bad_frontmatter_does_not_abort_chunking passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "---\nname: [\n---\n# H\nbody\n"
+ path = _write_md(tmp, "bad-frontmatter.md", body)
+ chunker = MarkdownFileChunker(chunk_byte_size=500)
+ node, chunks = await chunker.chunk(path)
+ assert node.front_matter.name == ""
+ assert len(chunks) == 1
+ assert chunks[0].start_line == 4
+ assert "body" in chunks[0].text
-def test_chunk_does_not_split_wikilink_at_boundary():
+@pytest.mark.asyncio
+async def test_chunk_does_not_split_wikilink_at_boundary():
"""A wikilink straddling the chunk_byte_size boundary should be retreated to its start."""
- async def run():
- # Pre-link filler is 90 bytes, link itself is 19 bytes ("[[a-very-long-target]]"=22).
- # With chunk_byte_size=100, the boundary lands inside the link.
- prefix = "x" * 90
- link = "[[a-very-long-target]]" # 22 bytes
- suffix = "y" * 90
- content = f"# H\n{prefix}{link}{suffix}"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- f.write(content)
- temp_path = f.name
+ # Pre-link filler is 90 bytes, link itself is 19 bytes ("[[a-very-long-target]]"=22).
+ # With chunk_byte_size=100, the boundary lands inside the link.
+ prefix = "x" * 90
+ link = "[[a-very-long-target]]" # 22 bytes
+ suffix = "y" * 90
+ content = f"# H\n{prefix}{link}{suffix}"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write(content)
+ temp_path = f.name
- try:
- chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=10, max_ast_sections=0)
- _, chunks = await chunker.chunk(temp_path)
- # The first chunk must NOT contain a partial link.
- first = chunks[0].text
- assert "[[" not in first or "]]" in first, f"first chunk has dangling '[[': {first!r}"
- # And the link should appear intact in some chunk.
- assert any(link in c.text for c in chunks), "link was split across all chunks"
- print("✓ test_chunk_does_not_split_wikilink_at_boundary passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
+ try:
+ chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=10, max_ast_sections=0)
+ _, chunks = await chunker.chunk(temp_path)
+ # The first chunk must NOT contain a partial link.
+ first = chunks[0].text
+ assert "[[" not in first or "]]" in first, f"first chunk has dangling '[[': {first!r}"
+ # And the link should appear intact in some chunk.
+ assert any(link in c.text for c in chunks), "link was split across all chunks"
+ finally:
+ os.unlink(temp_path)
-def test_chunk_does_not_split_wikilink_in_overlap():
+@pytest.mark.asyncio
+async def test_chunk_does_not_split_wikilink_in_overlap():
"""A wikilink landing inside the overlap region should be advanced past."""
- async def run():
- # 200-byte content, chunk=100, overlap=20. First chunk ends near byte 100,
- # next start = 80. Place a link straddling byte 80 to land in the overlap.
- prefix = "a" * 75
- link = "[[overlap-target]]" # 18 bytes; spans bytes 75..93
- suffix = "b" * 110
- content = f"# H\n{prefix}{link}{suffix}"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- f.write(content)
- temp_path = f.name
+ # 200-byte content, chunk=100, overlap=20. First chunk ends near byte 100,
+ # next start = 80. Place a link straddling byte 80 to land in the overlap.
+ prefix = "a" * 75
+ link = "[[overlap-target]]" # 18 bytes; spans bytes 75..93
+ suffix = "b" * 110
+ content = f"# H\n{prefix}{link}{suffix}"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write(content)
+ temp_path = f.name
- try:
- chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=20, max_ast_sections=0)
- _, chunks = await chunker.chunk(temp_path)
- # No chunk should start mid-link.
- for c in chunks:
- t = c.text
- if "]]" in t and "[[" not in t.split("]]", 1)[0]:
- raise AssertionError(f"chunk starts mid-link: {t[:40]!r}")
- assert any(link in c.text for c in chunks)
- print("✓ test_chunk_does_not_split_wikilink_in_overlap passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
+ try:
+ chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=20, max_ast_sections=0)
+ _, chunks = await chunker.chunk(temp_path)
+ # No chunk should start mid-link.
+ for c in chunks:
+ t = c.text
+ if "]]" in t and "[[" not in t.split("]]", 1)[0]:
+ raise AssertionError(f"chunk starts mid-link: {t[:40]!r}")
+ assert any(link in c.text for c in chunks)
+ finally:
+ os.unlink(temp_path)
-def test_chunk_falls_back_for_oversize_link():
+@pytest.mark.asyncio
+async def test_chunk_falls_back_for_oversize_link():
"""If a single link exceeds half the chunk size, the parser hard-cuts to make progress."""
- async def run():
- # chunk=100, link is 80 bytes, surrounded by short filler.
- # Retreating would leave a tiny chunk (< 50), so the fallback kicks in.
- prefix = "x" * 30
- link = "[[" + ("L" * 76) + "]]" # 80 bytes total
- suffix = "y" * 200
- content = f"# H\n{prefix}{link}{suffix}"
- with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
- f.write(content)
- temp_path = f.name
+ # chunk=100, link is 80 bytes, surrounded by short filler.
+ # Retreating would leave a tiny chunk (< 50), so the fallback kicks in.
+ prefix = "x" * 30
+ link = "[[" + ("L" * 76) + "]]" # 80 bytes total
+ suffix = "y" * 200
+ content = f"# H\n{prefix}{link}{suffix}"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write(content)
+ temp_path = f.name
- try:
- chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=10, max_ast_sections=0)
- _, chunks = await chunker.chunk(temp_path)
- # Must terminate (not hang) and cover the whole file.
- assert len(chunks) >= 2
- print("✓ test_chunk_falls_back_for_oversize_link passed")
- finally:
- os.unlink(temp_path)
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== MarkdownFileChunker tests ===")
- test_parse_empty_file()
- test_parse_frontmatter_only()
- test_parse_frontmatter_metadata_is_opt_in()
- test_parse_small_body_one_chunk()
- test_parse_small_children_are_cached_without_recursive_calls()
- test_parse_child_cache_flushes_at_exact_limit()
- test_parse_oversized_child_flushes_parent_cache()
- test_parse_oversized_body_splits()
- test_parse_chunk_ids_match_node_chunk_ids()
- test_parse_links_literal_targets()
- test_parse_links_short_and_no_ext_kept_literally()
- test_parse_links_legacy_relation_text_is_ignored()
- test_parse_links_deduped()
- test_parse_min_chunk_byte_size_clamped()
- test_parse_embed_toc_prefixes_chunk_text()
- test_parse_embed_toc_is_enabled_by_default()
- test_count_sections_ignores_fenced_headings_and_supports_setext()
- test_parse_excessive_sections_uses_plain_text_without_ast()
- test_parse_section_limit_is_inclusive_for_ast()
- test_parse_small_sections_are_merged_and_headings_preserved()
- test_parse_embed_toc_uses_breadcrumbs_without_sibling_duplication()
- test_parse_heading_heavy_output_grows_linearly()
- test_parse_nested_sections_merge_within_recursive_context()
- test_parse_breadcrumbs_and_part_markers_share_byte_budget()
- test_parse_frontmatter_preserves_original_line_numbers()
- test_parse_frontmatter_offsets_split_table_rows()
- test_parse_bad_frontmatter_does_not_abort_chunking()
- print("\n所有测试通过!")
+ try:
+ chunker = MarkdownFileChunker(chunk_byte_size=100, overlap_byte_size=10, max_ast_sections=0)
+ _, chunks = await chunker.chunk(temp_path)
+ # Must terminate (not hang) and cover the whole file.
+ assert len(chunks) >= 2
+ finally:
+ os.unlink(temp_path)
diff --git a/tests/unit/test_path_permission.py b/tests/unit/test_path_permission.py
index e8ab63d8..4e22823a 100644
--- a/tests/unit/test_path_permission.py
+++ b/tests/unit/test_path_permission.py
@@ -17,7 +17,7 @@ Covers:
# pylint: disable=protected-access
-import os
+from contextlib import chdir as temp_chdir
import tempfile
from pathlib import Path
@@ -31,22 +31,6 @@ from reme.steps.file_io.read import ReadStep
from reme.steps.file_io.write import WriteStep
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
def _seed(workspace: Path, rel: str, body: str = "body\n") -> Path:
target = workspace / rel
target.parent.mkdir(parents=True, exist_ok=True)
@@ -257,7 +241,3 @@ async def test_no_constraint_allows_all():
resp = await _run(WriteStep, store, path="anywhere/new.md", content="ok")
assert resp.success is True
await store.close()
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/unit/test_proactive_refresh.py b/tests/unit/test_proactive_refresh.py
index 282ab55e..08d3f2e4 100644
--- a/tests/unit/test_proactive_refresh.py
+++ b/tests/unit/test_proactive_refresh.py
@@ -13,6 +13,8 @@ import time
from pathlib import Path
from unittest.mock import patch
+import pytest
+
import yaml
from agentscope.model import ChatModelBase
@@ -282,7 +284,8 @@ async def _run_chain(
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_finish)
-def test_known_threshold_model_binding(tmp_path):
+@pytest.mark.asyncio
+async def test_known_threshold_model_binding(tmp_path):
"""known_threshold is model-bound (v5.1): fingerprint mismatch degrades.
The oracle maps every text to the same vector (sim 1.0 >= known_threshold),
@@ -291,30 +294,28 @@ def test_known_threshold_model_binding(tmp_path):
historical interests title still drops.
"""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "notes")
- _write_state(ws, open_topics=[_state_topic("检索引用质量评测")])
- _write_interests_v1(ws, "2026-08-12", [{"title": "向量数据库选型", "reason": "done"}])
- embedder = _FakeEmbedding({}, model="some-other-model-v9")
- reply = _reply(
- follow_ups=[
- _topic("检索引用可信度的评测方法", paths=[f"daily/{DAY}/session.md"]),
- _topic("向量数据库选型", paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
- state = context.get("proactive")
- assert state["dropped_known"] == 0 # semantic gate disabled by mismatch
- assert state["dropped_duplicate"] == 1 # exact compare still active
- titles = [t["title"] for t in _read_state(ws)["open_topics"]]
- assert "检索引用可信度的评测方法" in titles
- assert "向量数据库选型" not in titles
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "notes")
+ _write_state(ws, open_topics=[_state_topic("检索引用质量评测")])
+ _write_interests_v1(ws, "2026-08-12", [{"title": "向量数据库选型", "reason": "done"}])
+ embedder = _FakeEmbedding({}, model="some-other-model-v9")
+ reply = _reply(
+ follow_ups=[
+ _topic("检索引用可信度的评测方法", paths=[f"daily/{DAY}/session.md"]),
+ _topic("向量数据库选型", paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
+ state = context.get("proactive")
+ assert state["dropped_known"] == 0 # semantic gate disabled by mismatch
+ assert state["dropped_duplicate"] == 1 # exact compare still active
+ titles = [t["title"] for t in _read_state(ws)["open_topics"]]
+ assert "检索引用可信度的评测方法" in titles
+ assert "向量数据库选型" not in titles
-def test_overage_remention_restarts(tmp_path):
+@pytest.mark.asyncio
+async def test_overage_remention_restarts(tmp_path):
"""Over-age open topics re-mentioned today restart instead of vanishing.
Same-id candidate refreshes first_seen so trim keeps the topic (unfinished
@@ -324,38 +325,36 @@ def test_overage_remention_restarts(tmp_path):
its paraphrase re-mention survives and opens a fresh topic.
"""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "notes")
- old_day = "2026-07-28" # 16 days before DAY, over-age at carry_forward_days=14
- _write_state(
- ws,
- open_topics=[_state_topic("旧任务确认", day=old_day), _state_topic("遗留事项", day=old_day)],
- )
- # Discriminative oracle: only the legacy-task / its-supplement pair scores sim 1.0;
- # every other pair is orthogonal. If the dying title leaked into the
- # comparison set, the paraphrase below would drop as known.
- embedder = _FakeEmbedding({"遗留事项的补充。because": [1.0, 0.0], "遗留事项。seeded": [1.0, 0.0]})
- reply = _reply(
- follow_ups=[
- _topic("旧任务确认", paths=[f"daily/{DAY}/session.md"]),
- _topic("旧任务确认的后续安排", paths=[f"daily/{DAY}/session.md"]),
- _topic("遗留事项的补充", paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
- state = context.get("proactive")
- assert state["dropped_known"] == 1 # paraphrase of the REVIVED topic (default sim 1.0 vs it)
- topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
- assert topics[topic_id("旧任务确认")]["first_seen"] == DAY # restarted, not trimmed
- assert topic_id("旧任务确认的后续安排") not in topics # known vs revived topic
- assert topic_id("遗留事项的补充") in topics # dying title excluded from comparison
- assert topic_id("遗留事项") not in topics # over-age, never re-mentioned -> trimmed
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "notes")
+ old_day = "2026-07-28" # 16 days before DAY, over-age at carry_forward_days=14
+ _write_state(
+ ws,
+ open_topics=[_state_topic("旧任务确认", day=old_day), _state_topic("遗留事项", day=old_day)],
+ )
+ # Discriminative oracle: only the legacy-task / its-supplement pair scores sim 1.0;
+ # every other pair is orthogonal. If the dying title leaked into the
+ # comparison set, the paraphrase below would drop as known.
+ embedder = _FakeEmbedding({"遗留事项的补充。because": [1.0, 0.0], "遗留事项。seeded": [1.0, 0.0]})
+ reply = _reply(
+ follow_ups=[
+ _topic("旧任务确认", paths=[f"daily/{DAY}/session.md"]),
+ _topic("旧任务确认的后续安排", paths=[f"daily/{DAY}/session.md"]),
+ _topic("遗留事项的补充", paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
+ state = context.get("proactive")
+ assert state["dropped_known"] == 1 # paraphrase of the REVIVED topic (default sim 1.0 vs it)
+ topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
+ assert topics[topic_id("旧任务确认")]["first_seen"] == DAY # restarted, not trimmed
+ assert topic_id("旧任务确认的后续安排") not in topics # known vs revived topic
+ assert topic_id("遗留事项的补充") in topics # dying title excluded from comparison
+ assert topic_id("遗留事项") not in topics # over-age, never re-mentioned -> trimmed
-def test_update_evidence_anchor(tmp_path):
+@pytest.mark.asyncio
+async def test_update_evidence_anchor(tmp_path):
"""v5.2: action=update must anchor this round's new material (hard check).
An update citing a file outside the round's changed material degrades to
@@ -364,67 +363,62 @@ def test_update_evidence_anchor(tmp_path):
path date instead of defaulting to today.
"""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "today notes")
- _touch(ws / "daily" / "2026-08-12" / "old-note.md", "yesterday notes")
- _write_state(
- ws,
- open_topics=[_state_topic("证据改挂主题"), _state_topic("昨日证据主题")],
- )
- reply = _reply(
- updates=[
- {"id": topic_id("证据改挂主题"), "action": "update", "evidence": "daily/2026-08-11/absent.md"},
- {
- "id": topic_id("昨日证据主题"),
- "action": "update",
- "evidence": "daily/2026-08-12/old-note.md#L5",
- "confidence": 0.9,
- },
- ],
- )
- _, _, _, context, _ = await _run_chain(ws, [reply])
- state = context.get("proactive")
- assert state["updates_applied"] == 1
- topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
- rejected = topics[topic_id("证据改挂主题")]
- assert rejected["last_evidence_at"] == DAY # untouched, degraded to keep
- assert rejected["evidence"] != "daily/2026-08-11/absent.md"
- applied = topics[topic_id("昨日证据主题")]
- assert applied["last_evidence_at"] == "2026-08-12" # parsed from evidence path
- assert applied["evidence"] == "daily/2026-08-12/old-note.md#L5"
- assert applied["confidence"] == 0.9
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "today notes")
+ _touch(ws / "daily" / "2026-08-12" / "old-note.md", "yesterday notes")
+ _write_state(
+ ws,
+ open_topics=[_state_topic("证据改挂主题"), _state_topic("昨日证据主题")],
+ )
+ reply = _reply(
+ updates=[
+ {"id": topic_id("证据改挂主题"), "action": "update", "evidence": "daily/2026-08-11/absent.md"},
+ {
+ "id": topic_id("昨日证据主题"),
+ "action": "update",
+ "evidence": "daily/2026-08-12/old-note.md#L5",
+ "confidence": 0.9,
+ },
+ ],
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply])
+ state = context.get("proactive")
+ assert state["updates_applied"] == 1
+ topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
+ rejected = topics[topic_id("证据改挂主题")]
+ assert rejected["last_evidence_at"] == DAY # untouched, degraded to keep
+ assert rejected["evidence"] != "daily/2026-08-11/absent.md"
+ applied = topics[topic_id("昨日证据主题")]
+ assert applied["last_evidence_at"] == "2026-08-12" # parsed from evidence path
+ assert applied["evidence"] == "daily/2026-08-12/old-note.md#L5"
+ assert applied["confidence"] == 0.9
-def test_no_embedding_configured(tmp_path):
+@pytest.mark.asyncio
+async def test_no_embedding_configured(tmp_path):
"""No as_embedding anywhere: the chain runs and dedups exactly (BM25-only).
Paraphrase-level candidates survive (no semantic gate), exact normalize
duplicates of historical exposure still drop; nothing crashes or skips.
"""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "notes")
- _write_state(ws, open_topics=[_state_topic("已知主题")])
- _write_interests_v1(ws, "2026-08-12", [{"title": "历史曝光主题", "reason": "x"}])
- reply = _reply(
- follow_ups=[
- _topic("已知主题的不同说法", paths=[f"daily/{DAY}/session.md"]),
- _topic("历史曝光主题", paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- _, _, _, context, _ = await _run_chain(ws, [reply]) # no as_embedding in context/app
- state = context.get("proactive")
- assert state["dropped_known"] == 0
- assert state["dropped_duplicate"] == 1
- titles = [t["title"] for t in _read_state(ws)["open_topics"]]
- assert "已知主题的不同说法" in titles
- assert "历史曝光主题" not in titles
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "notes")
+ _write_state(ws, open_topics=[_state_topic("已知主题")])
+ _write_interests_v1(ws, "2026-08-12", [{"title": "历史曝光主题", "reason": "x"}])
+ reply = _reply(
+ follow_ups=[
+ _topic("已知主题的不同说法", paths=[f"daily/{DAY}/session.md"]),
+ _topic("历史曝光主题", paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply]) # no as_embedding in context/app
+ state = context.get("proactive")
+ assert state["dropped_known"] == 0
+ assert state["dropped_duplicate"] == 1
+ titles = [t["title"] for t in _read_state(ws)["open_topics"]]
+ assert "已知主题的不同说法" in titles
+ assert "历史曝光主题" not in titles
# ---------------------------------------------------------------------------
@@ -478,129 +472,119 @@ def test_field_fallback():
assert ProactiveTopic(confidence=-3).confidence == 0.0
-def test_resolved_registry(tmp_path):
+@pytest.mark.asyncio
+async def test_resolved_registry(tmp_path):
"""Resolved ids disappear from carry-forward and from F5 reads."""
- async def run():
- ws = tmp_path
- id_a, id_b = topic_id("Topic A"), topic_id("Topic B")
- _write_state(
- ws,
- open_topics=[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
- resolved=[{"id": id_b, "title": "Topic B", "resolved_at": DAY, "evidence": ""}],
- )
- state_file, needs_bootstrap = load_state(ws)
- assert needs_bootstrap is False
- carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
- assert [t.title for t in carry_all] == ["Topic A"]
+ ws = tmp_path
+ id_a, id_b = topic_id("Topic A"), topic_id("Topic B")
+ _write_state(
+ ws,
+ open_topics=[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
+ resolved=[{"id": id_b, "title": "Topic B", "resolved_at": DAY, "evidence": ""}],
+ )
+ state_file, needs_bootstrap = load_state(ws)
+ assert needs_bootstrap is False
+ carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
+ assert [t.title for t in carry_all] == ["Topic A"]
- _write_interests_v2(
- ws,
- DAY,
- [_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
- )
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
- response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
- assert [t["title"] for t in response.answer["topics"]] == ["Topic A"]
-
- asyncio.run(run())
+ _write_interests_v2(
+ ws,
+ DAY,
+ [_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
+ )
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
+ response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
+ assert [t["title"] for t in response.answer["topics"]] == ["Topic A"]
-def test_carry_forward_bootstrap(tmp_path):
+@pytest.mark.asyncio
+async def test_carry_forward_bootstrap(tmp_path):
"""First run (missing state file) bootstraps from interests.yaml history; first_seen = min."""
- async def run():
- ws = tmp_path
- _write_interests_v1(ws, "2026-08-11", [_topic("Topic T", paths=["daily/2026-08-11/a.md"])])
- _write_interests_v1(
- ws,
- "2026-08-12",
- [_topic("Topic T", paths=["daily/2026-08-12/a.md"]), _topic("Topic U", paths=["daily/2026-08-12/b.md"])],
- )
+ ws = tmp_path
+ _write_interests_v1(ws, "2026-08-11", [_topic("Topic T", paths=["daily/2026-08-11/a.md"])])
+ _write_interests_v1(
+ ws,
+ "2026-08-12",
+ [_topic("Topic T", paths=["daily/2026-08-12/a.md"]), _topic("Topic U", paths=["daily/2026-08-12/b.md"])],
+ )
- # Upgrade path: no _proactive.yaml yet -> one-time bootstrap from history.
- state_file, needs_bootstrap = load_state(ws)
- assert needs_bootstrap is True
- carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
- by_title = {t.title: t for t in carry_all}
- assert set(by_title) == {"Topic T", "Topic U"}
- assert by_title["Topic T"].first_seen == "2026-08-11" # min across days, not latest
- assert by_title["Topic U"].first_seen == "2026-08-12"
+ # Upgrade path: no _proactive.yaml yet -> one-time bootstrap from history.
+ state_file, needs_bootstrap = load_state(ws)
+ assert needs_bootstrap is True
+ carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
+ by_title = {t.title: t for t in carry_all}
+ assert set(by_title) == {"Topic T", "Topic U"}
+ assert by_title["Topic T"].first_seen == "2026-08-11" # min across days, not latest
+ assert by_title["Topic U"].first_seen == "2026-08-12"
- persisted = _read_state(ws)
- assert len(persisted["open_topics"]) == 2
- _, needs_bootstrap2 = load_state(ws)
- assert needs_bootstrap2 is False # no re-bootstrap once the key exists
+ persisted = _read_state(ws)
+ assert len(persisted["open_topics"]) == 2
+ _, needs_bootstrap2 = load_state(ws)
+ assert needs_bootstrap2 is False # no re-bootstrap once the key exists
- # Empty open_topics list is a normal state, not a bootstrap trigger.
- _write_state(ws, open_topics=[])
- _, needs_bootstrap3 = load_state(ws)
- assert needs_bootstrap3 is False
+ # Empty open_topics list is a normal state, not a bootstrap trigger.
+ _write_state(ws, open_topics=[])
+ _, needs_bootstrap3 = load_state(ws)
+ assert needs_bootstrap3 is False
- # An existing file lacking the key (e.g. hand-edited) still triggers it.
- _write_state(ws, budget={}, omit_open_topics=True)
- _, needs_bootstrap4 = load_state(ws)
- assert needs_bootstrap4 is True
-
- asyncio.run(run())
+ # An existing file lacking the key (e.g. hand-edited) still triggers it.
+ _write_state(ws, budget={}, omit_open_topics=True)
+ _, needs_bootstrap4 = load_state(ws)
+ assert needs_bootstrap4 is True
-def test_state_file_corrupt_no_bootstrap(tmp_path):
+@pytest.mark.asyncio
+async def test_state_file_corrupt_no_bootstrap(tmp_path):
"""Corrupt _proactive.yaml rebuilds empty WITHOUT bootstrap (spec F1.3/A2/A5)."""
- async def run():
- ws = tmp_path
- _write_interests_v1(ws, "2026-08-12", [_topic("Topic U", paths=["daily/2026-08-12/b.md"])])
- _touch(ws / "daily" / "_proactive.yaml", "version: [unclosed")
+ ws = tmp_path
+ _write_interests_v1(ws, "2026-08-12", [_topic("Topic U", paths=["daily/2026-08-12/b.md"])])
+ _touch(ws / "daily" / "_proactive.yaml", "version: [unclosed")
- state_file, needs_bootstrap = load_state(ws)
- assert needs_bootstrap is False
- assert state_file.open_topics == []
- carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
- assert carry_all == []
-
- asyncio.run(run())
+ state_file, needs_bootstrap = load_state(ws)
+ assert needs_bootstrap is False
+ assert state_file.open_topics == []
+ carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
+ assert carry_all == []
-def test_bootstrap_upgrade_chain(tmp_path):
+@pytest.mark.asyncio
+async def test_bootstrap_upgrade_chain(tmp_path):
"""Upgrade path: the first refresh run seeds the truth source from interests history."""
- async def run():
- ws = tmp_path
- _write_interests_v1(ws, "2026-08-12", [_topic("历史主题", paths=["daily/2026-08-12/a.md"])])
- _touch(ws / "daily" / DAY / "s1.md", "new evidence")
- reply = _reply(follow_ups=[_topic("新主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
- _, _, _, context, responses = await _run_chain(ws, [reply])
- assert all(r.success for r in responses)
+ ws = tmp_path
+ _write_interests_v1(ws, "2026-08-12", [_topic("历史主题", paths=["daily/2026-08-12/a.md"])])
+ _touch(ws / "daily" / DAY / "s1.md", "new evidence")
+ reply = _reply(follow_ups=[_topic("新主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
+ _, _, _, context, responses = await _run_chain(ws, [reply])
+ assert all(r.success for r in responses)
- assert context.get("proactive")["carry_forward_count"] == 1 # history reached the chain context
+ assert context.get("proactive")["carry_forward_count"] == 1 # history reached the chain context
- truth_titles = {t["title"] for t in _read_state(ws)["open_topics"]}
- assert truth_titles == {"历史主题", "新主题"}
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert {t["title"] for t in data["topics"]} == {"历史主题", "新主题"}
-
- asyncio.run(run())
+ truth_titles = {t["title"] for t in _read_state(ws)["open_topics"]}
+ assert truth_titles == {"历史主题", "新主题"}
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert {t["title"] for t in data["topics"]} == {"历史主题", "新主题"}
-def test_carry_forward_expiry(tmp_path):
+@pytest.mark.asyncio
+async def test_carry_forward_expiry(tmp_path):
"""Topics older than carry_forward_days drop from carry-forward and are pruned."""
- async def run():
- ws = tmp_path
- _write_state(ws, open_topics=[_state_topic("Old Topic", day="2026-07-01")])
- state_file, _ = load_state(ws)
- carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
- assert carry_all == []
+ ws = tmp_path
+ _write_state(ws, open_topics=[_state_topic("Old Topic", day="2026-07-01")])
+ state_file, _ = load_state(ws)
+ carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
+ assert carry_all == []
- _touch(ws / "daily" / DAY / "note.md", "new evidence")
- reply = _reply(follow_ups=[], updates=[])
- _, _, _, context, responses = await _run_chain(ws, [reply])
- assert all(r.success for r in responses)
- assert context.get("proactive")["carry_forward_count"] == 0
- assert _read_state(ws)["open_topics"] == [] # pruned from the truth source
-
- asyncio.run(run())
+ _touch(ws / "daily" / DAY / "note.md", "new evidence")
+ reply = _reply(follow_ups=[], updates=[])
+ _, _, _, context, responses = await _run_chain(ws, [reply])
+ assert all(r.success for r in responses)
+ assert context.get("proactive")["carry_forward_count"] == 0
+ assert _read_state(ws)["open_topics"] == [] # pruned from the truth source
def test_material_filter(tmp_path):
@@ -618,47 +602,45 @@ def test_material_filter(tmp_path):
# ---------------------------------------------------------------------------
-def test_refresh_chain_e2e(tmp_path):
+@pytest.mark.asyncio
+async def test_refresh_chain_e2e(tmp_path):
"""extract -> topics -> finish runs end to end."""
- async def run():
- ws = tmp_path
- note = _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan; nothing landed")
- reply = _reply(
- follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=["daily/2026-08-13/session.md"])],
- )
- app = ApplicationContext(workspace_dir=str(ws))
- wrapper = _AgentWrapper([reply])
- catalog = _Catalog()
- context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
+ ws = tmp_path
+ note = _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan; nothing landed")
+ reply = _reply(
+ follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=["daily/2026-08-13/session.md"])],
+ )
+ app = ApplicationContext(workspace_dir=str(ws))
+ wrapper = _AgentWrapper([reply])
+ catalog = _Catalog()
+ context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
- extract = ProactiveExtractStep(app_context=app)
- resp_extract = await extract(context)
- topics = ProactiveTopicsStep(app_context=app)
- resp_topics = await topics(context)
- finish = ProactiveFinishStep(app_context=app)
- resp_finish = await finish(context)
+ extract = ProactiveExtractStep(app_context=app)
+ resp_extract = await extract(context)
+ topics = ProactiveTopicsStep(app_context=app)
+ resp_topics = await topics(context)
+ finish = ProactiveFinishStep(app_context=app)
+ resp_finish = await finish(context)
- assert resp_extract.success and resp_topics.success and resp_finish.success
- state = context.get("proactive")
- assert state["llm_calls"] == 1
- assert state["push"] is True
- assert state["file_skip_reason"] == ""
- assert state["interests_written"] is True
+ assert resp_extract.success and resp_topics.success and resp_finish.success
+ state = context.get("proactive")
+ assert state["llm_calls"] == 1
+ assert state["push"] is True
+ assert state["file_skip_reason"] == ""
+ assert state["interests_written"] is True
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["version"] == 2
- assert data["push"] is True
- assert data["topics"][0]["title"] == "记忆检索的可解释性评估"
- assert data["topics"][0]["kind"] == "follow_up"
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["version"] == 2
+ assert data["push"] is True
+ assert data["topics"][0]["title"] == "记忆检索的可解释性评估"
+ assert data["topics"][0]["kind"] == "follow_up"
- truth = _read_state(ws)
- assert len(truth["open_topics"]) == 1
- checkpoint_paths = {n.path for n in catalog.upserts}
- assert checkpoint_paths == {note.relative_to(ws).as_posix()} # interests.yaml not checkpointed (R6)
- assert catalog.dumps == 1
-
- asyncio.run(run())
+ truth = _read_state(ws)
+ assert len(truth["open_topics"]) == 1
+ checkpoint_paths = {n.path for n in catalog.upserts}
+ assert checkpoint_paths == {note.relative_to(ws).as_posix()} # interests.yaml not checkpointed (R6)
+ assert catalog.dumps == 1
def test_open_loop_follow_up(tmp_path):
@@ -686,199 +668,185 @@ def test_open_loop_follow_up(tmp_path):
asyncio.run(run())
-def test_updates_actions(tmp_path):
+@pytest.mark.asyncio
+async def test_updates_actions(tmp_path):
"""keep/update/resolve act on the truth source; unknown ids ignored."""
- async def run():
- ws = tmp_path
- id_a, id_b, id_c = topic_id("Topic A"), topic_id("Topic B"), topic_id("Topic C")
- _write_state(
- ws,
- open_topics=[
- {**_state_topic("Topic A", day="2026-08-12", topic_id_value=id_a)},
- {**_state_topic("Topic B", day="2026-08-12", topic_id_value=id_b)},
- {**_state_topic("Topic C", day="2026-08-12", topic_id_value=id_c)},
- ],
- )
- _touch(ws / "daily" / DAY / "session.md", "new evidence today")
- evidence = f"daily/{DAY}/session.md"
- reply = _reply(
- follow_ups=[],
- updates=[
- {"id": id_a, "action": "keep"},
- {"id": id_b, "action": "update", "evidence": evidence},
- {"id": id_c, "action": "resolve", "evidence": evidence},
- {"id": "ffffffffffff", "action": "resolve"},
- ],
- )
- _, _, _, context, responses = await _run_chain(ws, [reply])
- assert all(r.success for r in responses)
- state = context.get("proactive")
- assert state["updates_applied"] == 1
- assert state["updates_resolved"] == 1
+ ws = tmp_path
+ id_a, id_b, id_c = topic_id("Topic A"), topic_id("Topic B"), topic_id("Topic C")
+ _write_state(
+ ws,
+ open_topics=[
+ {**_state_topic("Topic A", day="2026-08-12", topic_id_value=id_a)},
+ {**_state_topic("Topic B", day="2026-08-12", topic_id_value=id_b)},
+ {**_state_topic("Topic C", day="2026-08-12", topic_id_value=id_c)},
+ ],
+ )
+ _touch(ws / "daily" / DAY / "session.md", "new evidence today")
+ evidence = f"daily/{DAY}/session.md"
+ reply = _reply(
+ follow_ups=[],
+ updates=[
+ {"id": id_a, "action": "keep"},
+ {"id": id_b, "action": "update", "evidence": evidence},
+ {"id": id_c, "action": "resolve", "evidence": evidence},
+ {"id": "ffffffffffff", "action": "resolve"},
+ ],
+ )
+ _, _, _, context, responses = await _run_chain(ws, [reply])
+ assert all(r.success for r in responses)
+ state = context.get("proactive")
+ assert state["updates_applied"] == 1
+ assert state["updates_resolved"] == 1
- truth = _read_state(ws)
- by_id = {t["id"]: t for t in truth["open_topics"]}
- assert set(by_id) == {id_a, id_b}
- assert by_id[id_a]["last_evidence_at"] == "2026-08-12" # keep: untouched
- assert by_id[id_b]["last_evidence_at"] == DAY
- assert by_id[id_b]["evidence"] == evidence
- resolved_ids = {r["id"] for r in truth["resolved"]}
- assert resolved_ids == {id_c}
+ truth = _read_state(ws)
+ by_id = {t["id"]: t for t in truth["open_topics"]}
+ assert set(by_id) == {id_a, id_b}
+ assert by_id[id_a]["last_evidence_at"] == "2026-08-12" # keep: untouched
+ assert by_id[id_b]["last_evidence_at"] == DAY
+ assert by_id[id_b]["evidence"] == evidence
+ resolved_ids = {r["id"] for r in truth["resolved"]}
+ assert resolved_ids == {id_c}
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert {t["title"] for t in data["topics"]} == {"Topic A", "Topic B"}
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert {t["title"] for t in data["topics"]} == {"Topic A", "Topic B"}
-def test_resolved_resurrect(tmp_path):
+@pytest.mark.asyncio
+async def test_resolved_resurrect(tmp_path):
"""A candidate hitting a tombstone resurrects it: tombstone removed, first_seen kept."""
- async def run():
- ws = tmp_path
- tid = topic_id("Revived Topic")
- _write_state(
- ws,
- resolved=[
- {
- "id": tid,
- "title": "Revived Topic",
- "resolved_at": "2026-08-12",
- "first_seen": "2026-08-08",
- "evidence": "",
- },
- ],
- )
- _touch(ws / "daily" / DAY / "session.md", "the plan was reopened today")
- reply = _reply(follow_ups=[_topic("Revived Topic", confidence=0.7, paths=[f"daily/{DAY}/session.md"])])
- _, _, _, context, _ = await _run_chain(ws, [reply])
- state = context.get("proactive")
- assert [c["title"] for c in state["candidates"]] == ["Revived Topic"]
+ ws = tmp_path
+ tid = topic_id("Revived Topic")
+ _write_state(
+ ws,
+ resolved=[
+ {
+ "id": tid,
+ "title": "Revived Topic",
+ "resolved_at": "2026-08-12",
+ "first_seen": "2026-08-08",
+ "evidence": "",
+ },
+ ],
+ )
+ _touch(ws / "daily" / DAY / "session.md", "the plan was reopened today")
+ reply = _reply(follow_ups=[_topic("Revived Topic", confidence=0.7, paths=[f"daily/{DAY}/session.md"])])
+ _, _, _, context, _ = await _run_chain(ws, [reply])
+ state = context.get("proactive")
+ assert [c["title"] for c in state["candidates"]] == ["Revived Topic"]
- truth = _read_state(ws)
- assert truth["resolved"] == [] # tombstone removed
- by_id = {t["id"]: t for t in truth["open_topics"]}
- assert tid in by_id
- assert by_id[tid]["first_seen"] == "2026-08-08" # original age anchor kept
- assert by_id[tid]["last_evidence_at"] == DAY
-
- asyncio.run(run())
+ truth = _read_state(ws)
+ assert truth["resolved"] == [] # tombstone removed
+ by_id = {t["id"]: t for t in truth["open_topics"]}
+ assert tid in by_id
+ assert by_id[tid]["first_seen"] == "2026-08-08" # original age anchor kept
+ assert by_id[tid]["last_evidence_at"] == DAY
-def test_trim_freshness_first(tmp_path):
+@pytest.mark.asyncio
+async def test_trim_freshness_first(tmp_path):
"""max_topics trims by the freshness-first order, regardless of kind (v5 aging)."""
- async def run():
- ws = tmp_path
- stale = [
- {**_state_topic(f"Stale Follow {i}", day="2026-08-05", kind="follow_up"), "last_evidence_at": "2026-08-05"}
- for i in range(3)
- ]
- fresh = [_state_topic(f"Fresh Extend {i}", day=DAY, kind="interest_extend") for i in range(2)]
- _write_state(ws, open_topics=stale + fresh)
- _touch(ws / "daily" / DAY / "session.md", "material")
- reply = _reply(follow_ups=[_topic("New Follow", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
- _, _, _, context, _ = await _run_chain(ws, [reply], topics_kwargs={"max_topics": 4})
- titles = [t["title"] for t in context.get("proactive")["topics_out"]]
- assert len(titles) == 4
- assert titles[0] == "New Follow" # today's follow_up first
- assert set(titles[1:3]) == {"Fresh Extend 0", "Fresh Extend 1"} # today's extends next
- assert titles[3].startswith("Stale Follow") # only one stale topic survives the cap
-
- asyncio.run(run())
+ ws = tmp_path
+ stale = [
+ {**_state_topic(f"Stale Follow {i}", day="2026-08-05", kind="follow_up"), "last_evidence_at": "2026-08-05"}
+ for i in range(3)
+ ]
+ fresh = [_state_topic(f"Fresh Extend {i}", day=DAY, kind="interest_extend") for i in range(2)]
+ _write_state(ws, open_topics=stale + fresh)
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ reply = _reply(follow_ups=[_topic("New Follow", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
+ _, _, _, context, _ = await _run_chain(ws, [reply], topics_kwargs={"max_topics": 4})
+ titles = [t["title"] for t in context.get("proactive")["topics_out"]]
+ assert len(titles) == 4
+ assert titles[0] == "New Follow" # today's follow_up first
+ assert set(titles[1:3]) == {"Fresh Extend 0", "Fresh Extend 1"} # today's extends next
+ assert titles[3].startswith("Stale Follow") # only one stale topic survives the cap
-def test_dedup_fallback(tmp_path):
+@pytest.mark.asyncio
+async def test_dedup_fallback(tmp_path):
"""M1 fallback: normalize_topic exact match against recent interests drops duplicates."""
- async def run():
- ws = tmp_path
- _write_interests_v1(ws, "2026-08-12", [_topic("Retrieval Evaluation", paths=["daily/2026-08-12/a.md"])])
- # Seed an empty truth source so the history topic stays out of open_topics
- # (otherwise first-run bootstrap would merge the same-id candidate instead
- # of dropping it as a duplicate).
- _write_state(ws, open_topics=[])
- _touch(ws / "daily" / DAY / "session.md", "material")
- reply = _reply(
- follow_ups=[_topic("retrieval evaluation!!", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
- )
- _, _, _, context, _ = await _run_chain(ws, [reply])
- state = context.get("proactive")
- assert state["dropped_duplicate"] == 1
- assert state["candidates"] == []
- assert state["file_skip_reason"] == "all_duplicates" # metadata-only since v5 (R7)
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["push"] is False
- assert data["topics"] == []
-
- asyncio.run(run())
+ ws = tmp_path
+ _write_interests_v1(ws, "2026-08-12", [_topic("Retrieval Evaluation", paths=["daily/2026-08-12/a.md"])])
+ # Seed an empty truth source so the history topic stays out of open_topics
+ # (otherwise first-run bootstrap would merge the same-id candidate instead
+ # of dropping it as a duplicate).
+ _write_state(ws, open_topics=[])
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ reply = _reply(
+ follow_ups=[_topic("retrieval evaluation!!", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply])
+ state = context.get("proactive")
+ assert state["dropped_duplicate"] == 1
+ assert state["candidates"] == []
+ assert state["file_skip_reason"] == "all_duplicates" # metadata-only since v5 (R7)
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["push"] is False
+ assert data["topics"] == []
-def test_skip_low_confidence(tmp_path):
+@pytest.mark.asyncio
+async def test_skip_low_confidence(tmp_path):
"""Candidates under min_push_confidence persist but are not pushed."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "weak signal")
- reply = _reply(follow_ups=[_topic("弱信号主题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])])
- _, _, _, context, _ = await _run_chain(ws, [reply])
- state = context.get("proactive")
- assert state["push"] is False
- assert state["file_skip_reason"] == "low_confidence"
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["push"] is False
- assert "skip_reason" not in data # metadata-only since v5 (R7)
- assert [t["title"] for t in data["topics"]] == ["弱信号主题"] # rendered from truth source
- assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["弱信号主题"] # persisted
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "weak signal")
+ reply = _reply(follow_ups=[_topic("弱信号主题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])])
+ _, _, _, context, _ = await _run_chain(ws, [reply])
+ state = context.get("proactive")
+ assert state["push"] is False
+ assert state["file_skip_reason"] == "low_confidence"
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["push"] is False
+ assert "skip_reason" not in data # metadata-only since v5 (R7)
+ assert [t["title"] for t in data["topics"]] == ["弱信号主题"] # rendered from truth source
+ assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["弱信号主题"] # persisted
-def test_no_new_evidence_zero_llm(tmp_path):
+@pytest.mark.asyncio
+async def test_no_new_evidence_zero_llm(tmp_path):
"""No changed material -> early exit with zero LLM calls."""
- async def run():
- ws = tmp_path
- note = _touch(ws / "daily" / DAY / "session.md", "already seen")
- rel = note.relative_to(ws).as_posix()
- catalog = _Catalog([FileNode(path=rel, st_mtime=note.stat().st_mtime)])
- wrapper = _AgentWrapper(["should never be consumed"])
- _, wrapper, catalog, context, responses = await _run_chain(
- ws,
- wrapper=wrapper,
- catalog=catalog,
- )
- state = context.get("proactive")
- assert state["early_exit"] == "no_new_evidence"
- assert wrapper.calls == 0
- assert not _interests(ws).exists()
- assert catalog.upserts == [] and catalog.dumps == 0
- assert all(r.success for r in responses)
-
- asyncio.run(run())
+ ws = tmp_path
+ note = _touch(ws / "daily" / DAY / "session.md", "already seen")
+ rel = note.relative_to(ws).as_posix()
+ catalog = _Catalog([FileNode(path=rel, st_mtime=note.stat().st_mtime)])
+ wrapper = _AgentWrapper(["should never be consumed"])
+ _, wrapper, catalog, context, responses = await _run_chain(
+ ws,
+ wrapper=wrapper,
+ catalog=catalog,
+ )
+ state = context.get("proactive")
+ assert state["early_exit"] == "no_new_evidence"
+ assert wrapper.calls == 0
+ assert not _interests(ws).exists()
+ assert catalog.upserts == [] and catalog.dumps == 0
+ assert all(r.success for r in responses)
-def test_push_sticky(tmp_path):
+@pytest.mark.asyncio
+async def test_push_sticky(tmp_path):
"""Within one day push only goes up: derived from the cumulative truth source (R1)."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "s1.md", "strong evidence")
- reply1 = _reply(follow_ups=[_topic("高置信主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
- _, _, _, context1, _ = await _run_chain(ws, [reply1])
- assert context1.get("proactive")["push"] is True
- assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "s1.md", "strong evidence")
+ reply1 = _reply(follow_ups=[_topic("高置信主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
+ _, _, _, context1, _ = await _run_chain(ws, [reply1])
+ assert context1.get("proactive")["push"] is True
+ assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
- _touch(ws / "daily" / DAY / "s2.md", "weak evidence")
- reply2 = _reply(follow_ups=[_topic("低置信主题", confidence=0.3, paths=[f"daily/{DAY}/s2.md"])])
- _, _, _, context2, _ = await _run_chain(ws, [reply2])
- state2 = context2.get("proactive")
- assert state2["push"] is True # sticky: the 0.9 topic discovered today persists in truth source
- assert state2["file_skip_reason"] == ""
- assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
-
- asyncio.run(run())
+ _touch(ws / "daily" / DAY / "s2.md", "weak evidence")
+ reply2 = _reply(follow_ups=[_topic("低置信主题", confidence=0.3, paths=[f"daily/{DAY}/s2.md"])])
+ _, _, _, context2, _ = await _run_chain(ws, [reply2])
+ state2 = context2.get("proactive")
+ assert state2["push"] is True # sticky: the 0.9 topic discovered today persists in truth source
+ assert state2["file_skip_reason"] == ""
+ assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
def test_idempotent_write(tmp_path):
@@ -934,81 +902,75 @@ def test_idempotent_write(tmp_path):
# ---------------------------------------------------------------------------
-def test_refresh_isolation(tmp_path):
+@pytest.mark.asyncio
+async def test_refresh_isolation(tmp_path):
"""Refresh never writes digest, never touches the dream catalog or day index."""
- async def run():
- ws = tmp_path
- digest_file = _touch(ws / "digest" / "wiki" / "existing.md", "digest content")
- day_index = _touch(ws / "daily" / f"{DAY}.md", "index content")
- _touch(ws / "daily" / DAY / "session.md", "conversation")
- reply = _reply(follow_ups=[_topic("隔离主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
- proactive_catalog = _Catalog()
- dream_catalog = _Catalog()
- _, _, _, _, responses = await _run_chain(ws, [reply], catalog=proactive_catalog)
- assert all(r.success for r in responses)
+ ws = tmp_path
+ digest_file = _touch(ws / "digest" / "wiki" / "existing.md", "digest content")
+ day_index = _touch(ws / "daily" / f"{DAY}.md", "index content")
+ _touch(ws / "daily" / DAY / "session.md", "conversation")
+ reply = _reply(follow_ups=[_topic("隔离主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
+ proactive_catalog = _Catalog()
+ dream_catalog = _Catalog()
+ _, _, _, _, responses = await _run_chain(ws, [reply], catalog=proactive_catalog)
+ assert all(r.success for r in responses)
- assert digest_file.read_text(encoding="utf-8") == "digest content"
- assert day_index.read_text(encoding="utf-8") == "index content"
- assert not dream_catalog.upserts and dream_catalog.dumps == 0 and not dream_catalog.deleted
- assert proactive_catalog.dumps == 1
- assert {n.path for n in proactive_catalog.upserts} == {f"daily/{DAY}/session.md"}
-
- asyncio.run(run())
+ assert digest_file.read_text(encoding="utf-8") == "digest content"
+ assert day_index.read_text(encoding="utf-8") == "index content"
+ assert not dream_catalog.upserts and dream_catalog.dumps == 0 and not dream_catalog.deleted
+ assert proactive_catalog.dumps == 1
+ assert {n.path for n in proactive_catalog.upserts} == {f"daily/{DAY}/session.md"}
-def test_nightly_overwrite_continuity(tmp_path):
+@pytest.mark.asyncio
+async def test_nightly_overwrite_continuity(tmp_path):
"""Nightly v1 overwrite cannot break continuity: truth source restores exposure."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "s1.md", "round one")
- reply1 = _reply(follow_ups=[_topic("持续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
- await _run_chain(ws, [reply1])
- truth_before = _read_state(ws)["open_topics"]
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "s1.md", "round one")
+ reply1 = _reply(follow_ups=[_topic("持续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
+ await _run_chain(ws, [reply1])
+ truth_before = _read_state(ws)["open_topics"]
- # Nightly dream overwrites the exposure product with a v1 file.
- _write_interests_v1(ws, DAY, [_topic("Nightly only", paths=[f"daily/{DAY}/s1.md"])])
+ # Nightly dream overwrites the exposure product with a v1 file.
+ _write_interests_v1(ws, DAY, [_topic("Nightly only", paths=[f"daily/{DAY}/s1.md"])])
- _touch(ws / "daily" / DAY / "s2.md", "round two")
- reply2 = _reply(follow_ups=[], updates=[])
- _, _, _, context, _ = await _run_chain(ws, [reply2])
- assert context.get("proactive")["interests_written"] is True
+ _touch(ws / "daily" / DAY / "s2.md", "round two")
+ reply2 = _reply(follow_ups=[], updates=[])
+ _, _, _, context, _ = await _run_chain(ws, [reply2])
+ assert context.get("proactive")["interests_written"] is True
- assert _read_state(ws)["open_topics"] == truth_before # truth source intact
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["version"] == 2 # exposure restored from the truth source
- assert [t["title"] for t in data["topics"]] == ["持续主题"]
-
- asyncio.run(run())
+ assert _read_state(ws)["open_topics"] == truth_before # truth source intact
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["version"] == 2 # exposure restored from the truth source
+ assert [t["title"] for t in data["topics"]] == ["持续主题"]
-def test_refresh_nightly_interleave(tmp_path):
+@pytest.mark.asyncio
+async def test_refresh_nightly_interleave(tmp_path):
"""Refresh and nightly writes interleave without corrupting state or continuity."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "s1.md", "round one")
- reply1 = _reply(follow_ups=[_topic("连续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
- await _run_chain(ws, [reply1])
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "s1.md", "round one")
+ reply1 = _reply(follow_ups=[_topic("连续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
+ await _run_chain(ws, [reply1])
- # Nightly dream overwrites interests.yaml with a v1 file.
- _write_interests_v1(ws, DAY, [_topic("Nightly topic", paths=[f"daily/{DAY}/s1.md"])])
- nightly = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert nightly.get("version") is None
+ # Nightly dream overwrites interests.yaml with a v1 file.
+ _write_interests_v1(ws, DAY, [_topic("Nightly topic", paths=[f"daily/{DAY}/s1.md"])])
+ nightly = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert nightly.get("version") is None
- # Next refresh round re-renders from the intact truth source.
- _touch(ws / "daily" / DAY / "s2.md", "round two")
- reply2 = _reply(follow_ups=[], updates=[])
- _, _, _, _, responses = await _run_chain(ws, [reply2])
- assert all(r.success for r in responses)
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["version"] == 2
- assert data["push"] is True
- assert [t["title"] for t in data["topics"]] == ["连续主题"]
- assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["连续主题"]
-
- asyncio.run(run())
+ # Next refresh round re-renders from the intact truth source.
+ _touch(ws / "daily" / DAY / "s2.md", "round two")
+ reply2 = _reply(follow_ups=[], updates=[])
+ _, _, _, _, responses = await _run_chain(ws, [reply2])
+ assert all(r.success for r in responses)
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["version"] == 2
+ assert data["push"] is True
+ assert [t["title"] for t in data["topics"]] == ["连续主题"]
+ assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["连续主题"]
def test_nightly_unit_set_invariant():
@@ -1065,66 +1027,60 @@ def test_nightly_unit_set_invariant():
# ---------------------------------------------------------------------------
-def test_llm_timeout(tmp_path):
+@pytest.mark.asyncio
+async def test_llm_timeout(tmp_path):
"""A hanging reply times out, short-circuits the round, and retries next round."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "fresh material")
- wrapper = _AgentWrapper(["never returned"], delay=0.5)
- catalog = _Catalog()
- _, wrapper, catalog, context, responses = await _run_chain(
- ws,
- wrapper=wrapper,
- catalog=catalog,
- extract_kwargs={"llm_timeout_seconds": 0.1},
- )
- assert context.get("proactive_skip")["reason"] == "llm_timeout"
- assert wrapper.calls == 1
- assert not _interests(ws).exists()
- assert catalog.upserts == [] and catalog.dumps == 0
- assert all(r.success for r in responses)
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "fresh material")
+ wrapper = _AgentWrapper(["never returned"], delay=0.5)
+ catalog = _Catalog()
+ _, wrapper, catalog, context, responses = await _run_chain(
+ ws,
+ wrapper=wrapper,
+ catalog=catalog,
+ extract_kwargs={"llm_timeout_seconds": 0.1},
+ )
+ assert context.get("proactive_skip")["reason"] == "llm_timeout"
+ assert wrapper.calls == 1
+ assert not _interests(ws).exists()
+ assert catalog.upserts == [] and catalog.dumps == 0
+ assert all(r.success for r in responses)
-def test_parse_failure_retry_then_empty(tmp_path):
+@pytest.mark.asyncio
+async def test_parse_failure_retry_then_empty(tmp_path):
"""Unparseable output retries once; still failing yields empty, not error."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- wrapper = _AgentWrapper(["this is not structured at all", _reply(follow_ups=[], updates=[])])
- _, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
- assert wrapper.calls == 2
- assert all(r.success for r in responses)
- assert context.get("proactive")["follow_ups"] == []
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ wrapper = _AgentWrapper(["this is not structured at all", _reply(follow_ups=[], updates=[])])
+ _, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
+ assert wrapper.calls == 2
+ assert all(r.success for r in responses)
+ assert context.get("proactive")["follow_ups"] == []
-def test_parse_double_failure_keeps_material_uncheckpointed(tmp_path):
+@pytest.mark.asyncio
+async def test_parse_double_failure_keeps_material_uncheckpointed(tmp_path):
"""Two unusable replies skip without checkpoint; material retries next round (audit #1)."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- wrapper = _AgentWrapper(["garbage one", "garbage two"])
- catalog = _Catalog()
- app = ApplicationContext(workspace_dir=str(ws))
- context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
- extract = ProactiveExtractStep(app_context=app)
- resp_extract = await extract(context)
- topics = ProactiveTopicsStep(app_context=app)
- resp_topics = await topics(context)
- finish = ProactiveFinishStep(app_context=app)
- resp_finish = await finish(context)
- assert all(r.success for r in (resp_extract, resp_topics, resp_finish))
- assert wrapper.calls == 2
- assert context.get("proactive_skip") == {"reason": "extract_parse_failed"}
- assert not catalog.upserts # nothing checkpointed; material stays changed
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ wrapper = _AgentWrapper(["garbage one", "garbage two"])
+ catalog = _Catalog()
+ app = ApplicationContext(workspace_dir=str(ws))
+ context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
+ extract = ProactiveExtractStep(app_context=app)
+ resp_extract = await extract(context)
+ topics = ProactiveTopicsStep(app_context=app)
+ resp_topics = await topics(context)
+ finish = ProactiveFinishStep(app_context=app)
+ resp_finish = await finish(context)
+ assert all(r.success for r in (resp_extract, resp_topics, resp_finish))
+ assert wrapper.calls == 2
+ assert context.get("proactive_skip") == {"reason": "extract_parse_failed"}
+ assert not catalog.upserts # nothing checkpointed; material stays changed
def test_parse_extract_reply_schema_gate():
@@ -1135,21 +1091,19 @@ def test_parse_extract_reply_schema_gate():
assert parse_extract_reply("follow_ups: []\nupdates: []") == {"follow_ups": [], "updates": []}
-def test_schema_error_reply_retries(tmp_path):
+@pytest.mark.asyncio
+async def test_schema_error_reply_retries(tmp_path):
"""Non-empty YAML with wrong section names retries once like a parse failure (audit #1)."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- bad = "followups:\n - title: 拼错段名的话题\n reason: because\n"
- good = _reply(follow_ups=[_topic("补救话题", paths=[f"daily/{DAY}/session.md"])])
- wrapper = _AgentWrapper([bad, good])
- _, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
- assert wrapper.calls == 2
- assert all(r.success for r in responses)
- assert [t["title"] for t in context.get("proactive")["follow_ups"]] == ["补救话题"]
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ bad = "followups:\n - title: 拼错段名的话题\n reason: because\n"
+ good = _reply(follow_ups=[_topic("补救话题", paths=[f"daily/{DAY}/session.md"])])
+ wrapper = _AgentWrapper([bad, good])
+ _, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
+ assert wrapper.calls == 2
+ assert all(r.success for r in responses)
+ assert [t["title"] for t in context.get("proactive")["follow_ups"]] == ["补救话题"]
def test_pack_paths_total_budget(tmp_path):
@@ -1162,22 +1116,20 @@ def test_pack_paths_total_budget(tmp_path):
assert "(omitted 2 file(s)" in packed
-def test_extract_material_budget(tmp_path):
+@pytest.mark.asyncio
+async def test_extract_material_budget(tmp_path):
"""Blob packing honors max_total_chars; newest daily material is packed first (audit #5)."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / "2026-08-12" / "big-old.md", "x" * 500)
- _touch(ws / "daily" / DAY / "fresh.md", "fresh material")
- reply = _reply(follow_ups=[_topic("预算话题", paths=[f"daily/{DAY}/fresh.md"])])
- wrapper = _AgentWrapper([reply])
- await _run_chain(ws, wrapper=wrapper, extract_kwargs={"max_total_chars": 200})
- blob = wrapper.last_inputs[0]
- assert "### daily/2026-08-13/fresh.md" in blob # newest day survives the budget
- assert "### daily/2026-08-12/big-old.md" not in blob # omitted from the material section
- assert "omitted" in blob
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / "2026-08-12" / "big-old.md", "x" * 500)
+ _touch(ws / "daily" / DAY / "fresh.md", "fresh material")
+ reply = _reply(follow_ups=[_topic("预算话题", paths=[f"daily/{DAY}/fresh.md"])])
+ wrapper = _AgentWrapper([reply])
+ await _run_chain(ws, wrapper=wrapper, extract_kwargs={"max_total_chars": 200})
+ blob = wrapper.last_inputs[0]
+ assert "### daily/2026-08-13/fresh.md" in blob # newest day survives the budget
+ assert "### daily/2026-08-12/big-old.md" not in blob # omitted from the material section
+ assert "omitted" in blob
# ---------------------------------------------------------------------------
@@ -1185,71 +1137,65 @@ def test_extract_material_budget(tmp_path):
# ---------------------------------------------------------------------------
-def test_proactive_backward_compat(tmp_path):
+@pytest.mark.asyncio
+async def test_proactive_backward_compat(tmp_path):
"""Default params on a v1 file keep the legacy answer field-for-field."""
- async def run():
- content = (
- "date: 2026-05-28\n"
- "topics:\n"
- " - title: Retrieval quality\n"
- " reason: Search behavior changed repeatedly.\n"
- " evidence: daily/2026-05-28/session.md\n"
- )
- _touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- response = await step(
- RuntimeContext(date="2026-05-28", include_content=True, file_store=_FileStore(tmp_path)),
- )
- assert response.success is True
- assert response.answer == {
- "summary": "Read 1 proactive topic(s) from daily/2026-05-28/interests.yaml",
- "topics": [
- {
- "title": "Retrieval quality",
- "reason": "Search behavior changed repeatedly.",
- "evidence": "daily/2026-05-28/session.md",
- "paths": [],
- },
- ],
- "content": content,
- }
- assert response.metadata["topics"] == response.answer["topics"]
- assert response.metadata["content"] == content
- assert response.metadata["push"] is True # additive keys only
-
- asyncio.run(run())
+ content = (
+ "date: 2026-05-28\n"
+ "topics:\n"
+ " - title: Retrieval quality\n"
+ " reason: Search behavior changed repeatedly.\n"
+ " evidence: daily/2026-05-28/session.md\n"
+ )
+ _touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
+ response = await step(
+ RuntimeContext(date="2026-05-28", include_content=True, file_store=_FileStore(tmp_path)),
+ )
+ assert response.success is True
+ assert response.answer == {
+ "summary": "Read 1 proactive topic(s) from daily/2026-05-28/interests.yaml",
+ "topics": [
+ {
+ "title": "Retrieval quality",
+ "reason": "Search behavior changed repeatedly.",
+ "evidence": "daily/2026-05-28/session.md",
+ "paths": [],
+ },
+ ],
+ "content": content,
+ }
+ assert response.metadata["topics"] == response.answer["topics"]
+ assert response.metadata["content"] == content
+ assert response.metadata["push"] is True # additive keys only
-def test_proactive_answer_omits_unrequested_content(tmp_path):
+@pytest.mark.asyncio
+async def test_proactive_answer_omits_unrequested_content(tmp_path):
"""Raw YAML is absent from the primary answer when include_content is false."""
- async def run():
- _touch(tmp_path / "daily" / DAY / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n")
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
+ _touch(tmp_path / "daily" / DAY / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n")
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
+ response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
- assert response.success is True
- assert "content" not in response.answer
- assert response.answer["topics"][0]["title"] == "Topic"
- assert response.metadata["content"] == ""
-
- asyncio.run(run())
+ assert response.success is True
+ assert "content" not in response.answer
+ assert response.answer["topics"][0]["title"] == "Topic"
+ assert response.metadata["content"] == ""
-def test_proactive_missing_file_is_an_explicit_skip(tmp_path):
+@pytest.mark.asyncio
+async def test_proactive_missing_file_is_an_explicit_skip(tmp_path):
"""A missing single-day exposure remains distinguishable without metadata."""
- async def run():
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- response = await step(RuntimeContext(date=DAY, file_store=_FileStore(tmp_path)))
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
+ response = await step(RuntimeContext(date=DAY, file_store=_FileStore(tmp_path)))
- assert response.success is True
- assert response.answer == f"Skipped: interests file not found at daily/{DAY}/interests.yaml"
- assert response.metadata["skipped"] is True
-
- asyncio.run(run())
+ assert response.success is True
+ assert response.answer == f"Skipped: interests file not found at daily/{DAY}/interests.yaml"
+ assert response.metadata["skipped"] is True
def test_strict_topic_loading_rejects_lossy_fields(tmp_path):
@@ -1279,88 +1225,84 @@ def test_strict_topic_loading_rejects_invalid_field_types(tmp_path):
raise AssertionError("strict topic loading accepted an invalid paths type")
-def test_horizon_merge(tmp_path):
+@pytest.mark.asyncio
+async def test_horizon_merge(tmp_path):
"""Horizon>1 reads the truth source filtered by evidence recency (v5 R4)."""
- async def run():
- ws = tmp_path
- id_x, id_y = topic_id("Topic X"), topic_id("Topic Y")
- _write_state(
- ws,
- open_topics=[
- _state_topic("Topic X", day=DAY, topic_id_value=id_x, confidence=0.9),
- _state_topic("Topic Y", day="2026-08-09", topic_id_value=id_y, kind="interest_extend", confidence=0.6),
- ],
+ ws = tmp_path
+ id_x, id_y = topic_id("Topic X"), topic_id("Topic Y")
+ _write_state(
+ ws,
+ open_topics=[
+ _state_topic("Topic X", day=DAY, topic_id_value=id_x, confidence=0.9),
+ _state_topic("Topic Y", day="2026-08-09", topic_id_value=id_y, kind="interest_extend", confidence=0.6),
+ ],
+ )
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), horizon_days=3)
+ response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
+ assert response.success is True
+ assert [t["title"] for t in response.answer["topics"]] == ["Topic X"] # Y evidence too old
+ assert response.metadata["path"] == "daily/_proactive.yaml"
+
+ # A wider horizon includes the older topic again.
+ response2 = await step(
+ RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws), horizon_days=7),
+ )
+ assert {t["title"] for t in response2.answer["topics"]} == {"Topic X", "Topic Y"}
+
+ # Historical reads are bounded on both sides and cannot expose topics
+ # whose evidence is newer than the requested date.
+ historical = await step(
+ RuntimeContext(date="2026-08-10", include_content=False, file_store=_FileStore(ws), horizon_days=7),
+ )
+ assert [t["title"] for t in historical.answer["topics"]] == ["Topic Y"]
+
+ # Empty truth source -> skipped, not an error.
+ with tempfile.TemporaryDirectory() as tmp:
+ empty_ws = Path(tmp)
+ response3 = await step(
+ RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(empty_ws), horizon_days=3),
)
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), horizon_days=3)
- response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
- assert response.success is True
- assert [t["title"] for t in response.answer["topics"]] == ["Topic X"] # Y evidence too old
- assert response.metadata["path"] == "daily/_proactive.yaml"
-
- # A wider horizon includes the older topic again.
- response2 = await step(
- RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws), horizon_days=7),
- )
- assert {t["title"] for t in response2.answer["topics"]} == {"Topic X", "Topic Y"}
-
- # Historical reads are bounded on both sides and cannot expose topics
- # whose evidence is newer than the requested date.
- historical = await step(
- RuntimeContext(date="2026-08-10", include_content=False, file_store=_FileStore(ws), horizon_days=7),
- )
- assert [t["title"] for t in historical.answer["topics"]] == ["Topic Y"]
-
- # Empty truth source -> skipped, not an error.
- with tempfile.TemporaryDirectory() as tmp:
- empty_ws = Path(tmp)
- response3 = await step(
- RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(empty_ws), horizon_days=3),
- )
- assert response3.success is True
- assert response3.metadata["skipped"] is True
-
- asyncio.run(run())
+ assert response3.success is True
+ assert response3.metadata["skipped"] is True
-def test_min_confidence(tmp_path):
+@pytest.mark.asyncio
+async def test_min_confidence(tmp_path):
"""min_confidence filters v2 topics; v1 topics fall back to 0.5."""
- async def run():
- ws = tmp_path
- _write_interests_v2(
- ws,
- DAY,
- [
- _state_topic("High topic", confidence=0.7),
- _state_topic("Low topic", kind="interest_extend", confidence=0.3),
- ],
+ ws = tmp_path
+ _write_interests_v2(
+ ws,
+ DAY,
+ [
+ _state_topic("High topic", confidence=0.7),
+ _state_topic("Low topic", kind="interest_extend", confidence=0.3),
+ ],
+ )
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), min_confidence=0.5)
+ response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
+ assert [t["title"] for t in response.answer["topics"]] == ["High topic"]
+
+ # Default min_confidence=0.4 sits below the 0.5 fallback but above weak 0.3.
+ step_default = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
+ default_response = await step_default(
+ RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)),
+ )
+ assert [t["title"] for t in default_response.answer["topics"]] == ["High topic"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ ws_v1 = Path(tmp)
+ _write_interests_v1(ws_v1, DAY, [_topic("Legacy topic", paths=[f"daily/{DAY}/a.md"])])
+ step_v1 = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws_v1)))
+ ok = await step_v1(
+ RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.5),
)
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), min_confidence=0.5)
- response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
- assert [t["title"] for t in response.answer["topics"]] == ["High topic"]
-
- # Default min_confidence=0.4 sits below the 0.5 fallback but above weak 0.3.
- step_default = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
- default_response = await step_default(
- RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)),
+ assert [t["title"] for t in ok.answer["topics"]] == ["Legacy topic"] # fallback 0.5 passes
+ filtered = await step_v1(
+ RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.6),
)
- assert [t["title"] for t in default_response.answer["topics"]] == ["High topic"]
-
- with tempfile.TemporaryDirectory() as tmp:
- ws_v1 = Path(tmp)
- _write_interests_v1(ws_v1, DAY, [_topic("Legacy topic", paths=[f"daily/{DAY}/a.md"])])
- step_v1 = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws_v1)))
- ok = await step_v1(
- RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.5),
- )
- assert [t["title"] for t in ok.answer["topics"]] == ["Legacy topic"] # fallback 0.5 passes
- filtered = await step_v1(
- RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.6),
- )
- assert filtered.answer["topics"] == []
-
- asyncio.run(run())
+ assert filtered.answer["topics"] == []
# ---------------------------------------------------------------------------
@@ -1368,31 +1310,29 @@ def test_min_confidence(tmp_path):
# ---------------------------------------------------------------------------
-def test_extend_from_daily_material(tmp_path):
+@pytest.mark.asyncio
+async def test_extend_from_daily_material(tmp_path):
"""Branch B infers extends from daily material; out-of-M paths are dropped."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "discussion about source attribution")
- reply = _reply(
- follow_ups=[],
- extends=[
- _topic("引用质量评测", confidence=0.5, paths=[f"daily/{DAY}/session.md"]),
- _topic("越界扩展", confidence=0.5, paths=["daily/2026-08-01/absent.md"]),
- ],
- )
- _, wrapper, _, context, _ = await _run_chain(ws, [reply])
- assert "extends" in wrapper.last_inputs[0] # extends section rendered
- state = context.get("proactive")
- assert len(state["extends"]) == 1
- extend = state["extends"][0]
- assert extend["kind"] == "interest_extend"
- assert f"daily/{DAY}/session.md" in extend["paths"]
- assert state["dropped_missing"] == 1
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert [t["title"] for t in data["topics"]] == ["引用质量评测"]
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "discussion about source attribution")
+ reply = _reply(
+ follow_ups=[],
+ extends=[
+ _topic("引用质量评测", confidence=0.5, paths=[f"daily/{DAY}/session.md"]),
+ _topic("越界扩展", confidence=0.5, paths=["daily/2026-08-01/absent.md"]),
+ ],
+ )
+ _, wrapper, _, context, _ = await _run_chain(ws, [reply])
+ assert "extends" in wrapper.last_inputs[0] # extends section rendered
+ state = context.get("proactive")
+ assert len(state["extends"]) == 1
+ extend = state["extends"][0]
+ assert extend["kind"] == "interest_extend"
+ assert f"daily/{DAY}/session.md" in extend["paths"]
+ assert state["dropped_missing"] == 1
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert [t["title"] for t in data["topics"]] == ["引用质量评测"]
def test_extends_disabled_m1_shape():
@@ -1427,60 +1367,56 @@ def test_extends_disabled_m1_shape():
asyncio.run(run())
-def test_semantic_dedup(tmp_path):
+@pytest.mark.asyncio
+async def test_semantic_dedup(tmp_path):
"""With embeddings, similarity below known_threshold is kept; no LLM tier (v5 R2)."""
- async def run():
- ws = tmp_path
- _write_state(ws, open_topics=[_state_topic("Gray Comparison", kind="interest_extend")])
- _touch(ws / "daily" / DAY / "session.md", "material")
- reply = _reply(
- follow_ups=[
- _topic("Candidate Topic", confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- fake = _FakeEmbedding(
- {
- # v5.2 scheme: candidate and known sides both embed "title + CJK period + reason"
- "Candidate Topic。because": [1.0, 0.0],
- "Gray Comparison。seeded": [0.8, 0.6], # cos = 0.800 < 0.85 -> kept, no second tier
- },
- )
- _, wrapper, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
- state = context.get("proactive")
- assert state["dropped_known"] == 0
- assert state["dropped_duplicate"] == 0
- assert [c["title"] for c in state["candidates"]] == ["Candidate Topic"]
- assert fake.calls >= 1
- assert wrapper.calls == 1 # topics step is pure computation now
-
- asyncio.run(run())
+ ws = tmp_path
+ _write_state(ws, open_topics=[_state_topic("Gray Comparison", kind="interest_extend")])
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ reply = _reply(
+ follow_ups=[
+ _topic("Candidate Topic", confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ fake = _FakeEmbedding(
+ {
+ # v5.2 scheme: candidate and known sides both embed "title + CJK period + reason"
+ "Candidate Topic。because": [1.0, 0.0],
+ "Gray Comparison。seeded": [0.8, 0.6], # cos = 0.800 < 0.85 -> kept, no second tier
+ },
+ )
+ _, wrapper, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
+ state = context.get("proactive")
+ assert state["dropped_known"] == 0
+ assert state["dropped_duplicate"] == 0
+ assert [c["title"] for c in state["candidates"]] == ["Candidate Topic"]
+ assert fake.calls >= 1
+ assert wrapper.calls == 1 # topics step is pure computation now
-def test_known_drop(tmp_path):
+@pytest.mark.asyncio
+async def test_known_drop(tmp_path):
"""sim >= known_threshold drops the candidate as already known."""
- async def run():
- ws = tmp_path
- _write_state(ws, open_topics=[_state_topic("Known Topic", kind="interest_extend")])
- _touch(ws / "daily" / DAY / "session.md", "material")
- reply = _reply(
- follow_ups=[_topic("Fresh Candidate", confidence=0.8, paths=[f"daily/{DAY}/session.md"])],
- )
- fake = _FakeEmbedding(
- {
- "Fresh Candidate。because": [1.0, 0.0],
- "Known Topic。seeded": [1.0, 0.0], # identical -> sim 1.0 >= 0.85
- },
- )
- _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
- state = context.get("proactive")
- assert state["dropped_known"] == 1
- assert state["candidates"] == []
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert [t["title"] for t in data["topics"]] == ["Known Topic"]
-
- asyncio.run(run())
+ ws = tmp_path
+ _write_state(ws, open_topics=[_state_topic("Known Topic", kind="interest_extend")])
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ reply = _reply(
+ follow_ups=[_topic("Fresh Candidate", confidence=0.8, paths=[f"daily/{DAY}/session.md"])],
+ )
+ fake = _FakeEmbedding(
+ {
+ "Fresh Candidate。because": [1.0, 0.0],
+ "Known Topic。seeded": [1.0, 0.0], # identical -> sim 1.0 >= 0.85
+ },
+ )
+ _, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
+ state = context.get("proactive")
+ assert state["dropped_known"] == 1
+ assert state["candidates"] == []
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert [t["title"] for t in data["topics"]] == ["Known Topic"]
# ---------------------------------------------------------------------------
@@ -1549,282 +1485,266 @@ async def _run_full_chain(
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish)
-def test_plan_agenda_chain_e2e(tmp_path):
+@pytest.mark.asyncio
+async def test_plan_agenda_chain_e2e(tmp_path):
"""extract -> topics -> plan -> agenda -> finish writes the enriched v2 file."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan")
- extract_reply = _reply(
- follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
- extends=[_topic("多跳 RAG 综述", confidence=0.5, paths=[f"daily/{DAY}/session.md"])],
- )
- fid, eid = topic_id("记忆检索的可解释性评估"), topic_id("多跳 RAG 综述")
- plan_reply = _plan_reply(
- [
- {
- "topic_id": fid,
- "scenario_type": "resume_task",
- "opener": "对了,上次那个评估还差一步,要不要现在跑起来?",
- "next_action": "运行评估脚本",
- "preconditions": ["数据集已就绪"],
- "delivery": "agenda_item",
- },
- _card(eid, 1, scenario="explore_interest"),
- ],
- )
- agenda_text = _agenda_reply(
- [{"topic_id": fid, "order_reason": "卡点已解除,今天最适合收尾"}],
- [{"topic_id": eid, "reason": f"merged into {fid}"}],
- )
- _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
- assert all(r.success for r in resps)
- assert wrapper.calls == 3
- state = context.get("proactive")
- assert state["llm_calls"] == 1 # extract only
- assert state["plan_llm_calls"] == 2 # plan + agenda
- assert state["push"] is True
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan")
+ extract_reply = _reply(
+ follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
+ extends=[_topic("多跳 RAG 综述", confidence=0.5, paths=[f"daily/{DAY}/session.md"])],
+ )
+ fid, eid = topic_id("记忆检索的可解释性评估"), topic_id("多跳 RAG 综述")
+ plan_reply = _plan_reply(
+ [
+ {
+ "topic_id": fid,
+ "scenario_type": "resume_task",
+ "opener": "对了,上次那个评估还差一步,要不要现在跑起来?",
+ "next_action": "运行评估脚本",
+ "preconditions": ["数据集已就绪"],
+ "delivery": "agenda_item",
+ },
+ _card(eid, 1, scenario="explore_interest"),
+ ],
+ )
+ agenda_text = _agenda_reply(
+ [{"topic_id": fid, "order_reason": "卡点已解除,今天最适合收尾"}],
+ [{"topic_id": eid, "reason": f"merged into {fid}"}],
+ )
+ _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
+ assert all(r.success for r in resps)
+ assert wrapper.calls == 3
+ state = context.get("proactive")
+ assert state["llm_calls"] == 1 # extract only
+ assert state["plan_llm_calls"] == 2 # plan + agenda
+ assert state["push"] is True
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["version"] == 2
- assert data["push"] is True
- assert len(data["agenda"]) == 1
- item = data["agenda"][0]
- assert item["topic_id"] == fid
- assert item["title"] == "记忆检索的可解释性评估"
- assert item["scenario_type"] == "resume_task"
- assert item["opener"].startswith("对了")
- assert item["next_action"] == "运行评估脚本"
- assert item["preconditions"] == ["数据集已就绪"]
- assert item["delivery"] == "agenda_item"
- assert item["linked_memory"] == [f"daily/{DAY}/session.md"]
- assert item["order_reason"] == "卡点已解除,今天最适合收尾"
- assert data["suppressed"] == [
- {"topic_id": eid, "title": "多跳 RAG 综述", "reason": f"merged into {fid}"},
- ]
- assert {t["id"] for t in data["topics"]} == {fid, eid} # topics view intact
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["version"] == 2
+ assert data["push"] is True
+ assert len(data["agenda"]) == 1
+ item = data["agenda"][0]
+ assert item["topic_id"] == fid
+ assert item["title"] == "记忆检索的可解释性评估"
+ assert item["scenario_type"] == "resume_task"
+ assert item["opener"].startswith("对了")
+ assert item["next_action"] == "运行评估脚本"
+ assert item["preconditions"] == ["数据集已就绪"]
+ assert item["delivery"] == "agenda_item"
+ assert item["linked_memory"] == [f"daily/{DAY}/session.md"]
+ assert item["order_reason"] == "卡点已解除,今天最适合收尾"
+ assert data["suppressed"] == [
+ {"topic_id": eid, "title": "多跳 RAG 综述", "reason": f"merged into {fid}"},
+ ]
+ assert {t["id"] for t in data["topics"]} == {fid, eid} # topics view intact
-def test_plan_agenda_fallback_without_llm(tmp_path):
+@pytest.mark.asyncio
+async def test_plan_agenda_fallback_without_llm(tmp_path):
"""Unusable plan/agenda replies degrade to fallback cards + deterministic agenda."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- extract_reply = _reply(
- follow_ups=[
- _topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
- _topic("任务二", confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- # Only the extract reply is scripted; plan/agenda receive "" and must degrade.
- _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
- assert all(r.success for r in resps)
- assert wrapper.calls == 3
- state = context.get("proactive")
- assert state["plan_llm_calls"] == 2 # attempted but unusable
- assert len(state["scenario_cards"]) == 2
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ extract_reply = _reply(
+ follow_ups=[
+ _topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
+ _topic("任务二", confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ # Only the extract reply is scripted; plan/agenda receive "" and must degrade.
+ _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
+ assert all(r.success for r in resps)
+ assert wrapper.calls == 3
+ state = context.get("proactive")
+ assert state["plan_llm_calls"] == 2 # attempted but unusable
+ assert len(state["scenario_cards"]) == 2
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["push"] is True
- assert [i["title"] for i in data["agenda"]] == ["任务一", "任务二"]
- assert all(i["order_reason"] == "deterministic fallback: freshness/confidence order" for i in data["agenda"])
- assert data["suppressed"] == []
- assert all(i["scenario_type"] == "resume_task" for i in data["agenda"])
- assert all(i["opener"] for i in data["agenda"])
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["push"] is True
+ assert [i["title"] for i in data["agenda"]] == ["任务一", "任务二"]
+ assert all(i["order_reason"] == "deterministic fallback: freshness/confidence order" for i in data["agenda"])
+ assert data["suppressed"] == []
+ assert all(i["scenario_type"] == "resume_task" for i in data["agenda"])
+ assert all(i["opener"] for i in data["agenda"])
-def test_agenda_no_candidates_keeps_topics_render(tmp_path):
+@pytest.mark.asyncio
+async def test_agenda_no_candidates_keeps_topics_render(tmp_path):
"""Without push candidates plan/agenda skip and the topics-only render stays."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- extract_reply = _reply(
- follow_ups=[_topic("低置信话题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])],
- )
- _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
- assert all(r.success for r in resps)
- assert wrapper.calls == 1 # extract only; plan/agenda made zero LLM calls
- state = context.get("proactive")
- assert state["push"] is False
- assert state["push_candidates"] == []
- assert state["scenario_cards"] == []
- assert state["agenda"] == []
- assert state["suppressed"] == []
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ extract_reply = _reply(
+ follow_ups=[_topic("低置信话题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])],
+ )
+ _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
+ assert all(r.success for r in resps)
+ assert wrapper.calls == 1 # extract only; plan/agenda made zero LLM calls
+ state = context.get("proactive")
+ assert state["push"] is False
+ assert state["push_candidates"] == []
+ assert state["scenario_cards"] == []
+ assert state["agenda"] == []
+ assert state["suppressed"] == []
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert data["push"] is False
- assert "agenda" not in data and "suppressed" not in data
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert data["push"] is False
+ assert "agenda" not in data and "suppressed" not in data
-def test_agenda_single_candidate_skips_llm(tmp_path):
+@pytest.mark.asyncio
+async def test_agenda_single_candidate_skips_llm(tmp_path):
"""Exactly one candidate is auto-agenda without an agenda LLM call."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- extract_reply = _reply(
- follow_ups=[_topic("唯一候选", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
- )
- fid = topic_id("唯一候选")
- plan_reply = _plan_reply([_card(fid, scenario="answer_pending")])
- _, wrapper, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply])
- assert wrapper.calls == 2 # extract + plan only
- state = context.get("proactive")
- assert state["plan_llm_calls"] == 1
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ extract_reply = _reply(
+ follow_ups=[_topic("唯一候选", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
+ )
+ fid = topic_id("唯一候选")
+ plan_reply = _plan_reply([_card(fid, scenario="answer_pending")])
+ _, wrapper, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply])
+ assert wrapper.calls == 2 # extract + plan only
+ state = context.get("proactive")
+ assert state["plan_llm_calls"] == 1
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert len(data["agenda"]) == 1
- assert data["agenda"][0]["topic_id"] == fid
- assert data["agenda"][0]["order_reason"] == "single push candidate; auto-agenda without LLM"
- assert data["suppressed"] == []
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert len(data["agenda"]) == 1
+ assert data["agenda"][0]["topic_id"] == fid
+ assert data["agenda"][0]["order_reason"] == "single push candidate; auto-agenda without LLM"
+ assert data["suppressed"] == []
-def test_agenda_merge_and_full_accounting(tmp_path):
+@pytest.mark.asyncio
+async def test_agenda_merge_and_full_accounting(tmp_path):
"""LLM merges two entrances of one matter; every candidate is accounted for."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- titles = ["GPU 排期确认", "跑 benchmark 的 GPU 档期", "多跳引用可信度"]
- extract_reply = _reply(
- follow_ups=[
- _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
- _topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
- _topic(titles[2], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- ids = [topic_id(t) for t in titles]
- plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
- agenda_text = _agenda_reply(
- [{"topic_id": ids[0], "order_reason": "主入口"}, {"topic_id": ids[2], "order_reason": "独立事项"}],
- [{"topic_id": ids[1], "reason": f"merged into {ids[0]}"}],
- )
- _, _, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
- state = context.get("proactive")
- assert state["push"] is True
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ titles = ["GPU 排期确认", "跑 benchmark 的 GPU 档期", "多跳引用可信度"]
+ extract_reply = _reply(
+ follow_ups=[
+ _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
+ _topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
+ _topic(titles[2], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ ids = [topic_id(t) for t in titles]
+ plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
+ agenda_text = _agenda_reply(
+ [{"topic_id": ids[0], "order_reason": "主入口"}, {"topic_id": ids[2], "order_reason": "独立事项"}],
+ [{"topic_id": ids[1], "reason": f"merged into {ids[0]}"}],
+ )
+ _, _, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
+ state = context.get("proactive")
+ assert state["push"] is True
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[2]]
- assert data["suppressed"] == [
- {"topic_id": ids[1], "title": titles[1], "reason": f"merged into {ids[0]}"},
- ]
- accounted = {i["topic_id"] for i in data["agenda"]} | {x["topic_id"] for x in data["suppressed"]}
- assert accounted == set(ids)
-
- asyncio.run(run())
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[2]]
+ assert data["suppressed"] == [
+ {"topic_id": ids[1], "title": titles[1], "reason": f"merged into {ids[0]}"},
+ ]
+ accounted = {i["topic_id"] for i in data["agenda"]} | {x["topic_id"] for x in data["suppressed"]}
+ assert accounted == set(ids)
-def test_agenda_invalid_ids_and_missing_accounting(tmp_path):
+@pytest.mark.asyncio
+async def test_agenda_invalid_ids_and_missing_accounting(tmp_path):
"""Bogus agenda ids are dropped; candidates the LLM forgot are still suppressed."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- titles = ["候选甲", "候选乙"]
- extract_reply = _reply(
- follow_ups=[
- _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
- _topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- ids = [topic_id(t) for t in titles]
- plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
- agenda_text = _agenda_reply(
- [{"topic_id": "nonexistent01", "order_reason": "bogus"}, {"topic_id": ids[0], "order_reason": "real"}],
- [],
- )
- _, _, _, _, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert [i["topic_id"] for i in data["agenda"]] == [ids[0]]
- assert data["suppressed"] == [
- {"topic_id": ids[1], "title": titles[1], "reason": "not selected for today's agenda"},
- ]
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ titles = ["候选甲", "候选乙"]
+ extract_reply = _reply(
+ follow_ups=[
+ _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
+ _topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ ids = [topic_id(t) for t in titles]
+ plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
+ agenda_text = _agenda_reply(
+ [{"topic_id": "nonexistent01", "order_reason": "bogus"}, {"topic_id": ids[0], "order_reason": "real"}],
+ [],
+ )
+ _, _, _, _, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert [i["topic_id"] for i in data["agenda"]] == [ids[0]]
+ assert data["suppressed"] == [
+ {"topic_id": ids[1], "title": titles[1], "reason": "not selected for today's agenda"},
+ ]
-def test_plan_over_budget_suppressed(tmp_path):
+@pytest.mark.asyncio
+async def test_plan_over_budget_suppressed(tmp_path):
"""Candidates beyond max_plan_topics stay card-less and are suppressed with reason."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "session.md", "material")
- titles = ["高置信一", "高置信二", "高置信三"]
- extract_reply = _reply(
- follow_ups=[
- _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
- _topic(titles[1], confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
- _topic(titles[2], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
- ],
- )
- ids = [topic_id(t) for t in titles]
- # deterministic sort order: confidence desc -> ids[0], ids[1] selected; ids[2] over budget
- plan_reply = _plan_reply([_card(ids[0], 0), _card(ids[1], 1)])
- agenda_text = _agenda_reply(
- [{"topic_id": ids[0], "order_reason": "first"}, {"topic_id": ids[1], "order_reason": "second"}],
- [],
- )
- _, _, _, context, _ = await _run_full_chain(
- ws,
- [extract_reply, plan_reply, agenda_text],
- plan_kwargs={"max_plan_topics": 2},
- )
- state = context.get("proactive")
- assert len(state["scenario_cards"]) == 2
- data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
- assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[1]]
- assert data["suppressed"] == [
- {
- "topic_id": ids[2],
- "title": titles[2],
- "reason": "over plan budget: not expanded into a scenario card",
- },
- ]
-
- asyncio.run(run())
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "session.md", "material")
+ titles = ["高置信一", "高置信二", "高置信三"]
+ extract_reply = _reply(
+ follow_ups=[
+ _topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
+ _topic(titles[1], confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
+ _topic(titles[2], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
+ ],
+ )
+ ids = [topic_id(t) for t in titles]
+ # deterministic sort order: confidence desc -> ids[0], ids[1] selected; ids[2] over budget
+ plan_reply = _plan_reply([_card(ids[0], 0), _card(ids[1], 1)])
+ agenda_text = _agenda_reply(
+ [{"topic_id": ids[0], "order_reason": "first"}, {"topic_id": ids[1], "order_reason": "second"}],
+ [],
+ )
+ _, _, _, context, _ = await _run_full_chain(
+ ws,
+ [extract_reply, plan_reply, agenda_text],
+ plan_kwargs={"max_plan_topics": 2},
+ )
+ state = context.get("proactive")
+ assert len(state["scenario_cards"]) == 2
+ data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
+ assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[1]]
+ assert data["suppressed"] == [
+ {
+ "topic_id": ids[2],
+ "title": titles[2],
+ "reason": "over plan budget: not expanded into a scenario card",
+ },
+ ]
-def test_read_side_agenda_passthrough(tmp_path):
+@pytest.mark.asyncio
+async def test_read_side_agenda_passthrough(tmp_path):
"""F5 read passes the agenda through, filtering resolved/unknown/low-confidence ids."""
- async def run():
- ws = tmp_path
- id_a, id_b = topic_id("议程主题"), topic_id("已解决主题")
- payload = {
- "version": 2,
- "date": DAY,
- "generated_at": f"{DAY}T09:00:00",
- "push": True,
- "topics": [
- _state_topic("议程主题", topic_id_value=id_a, confidence=0.9),
- _state_topic("已解决主题", topic_id_value=id_b, confidence=0.9),
- ],
- "agenda": [
- {"topic_id": id_a, "opener": "对了,那件事可以动了"},
- {"topic_id": id_b, "opener": "resolved, must be filtered"},
- {"topic_id": "unknown000000", "opener": "not in kept"},
- ],
- "suppressed": [],
- }
- _touch(_interests(ws, DAY), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
- _write_state(ws, resolved=[{"id": id_b, "title": "已解决主题", "resolved_at": DAY, "evidence": ""}])
+ ws = tmp_path
+ id_a, id_b = topic_id("议程主题"), topic_id("已解决主题")
+ payload = {
+ "version": 2,
+ "date": DAY,
+ "generated_at": f"{DAY}T09:00:00",
+ "push": True,
+ "topics": [
+ _state_topic("议程主题", topic_id_value=id_a, confidence=0.9),
+ _state_topic("已解决主题", topic_id_value=id_b, confidence=0.9),
+ ],
+ "agenda": [
+ {"topic_id": id_a, "opener": "对了,那件事可以动了"},
+ {"topic_id": id_b, "opener": "resolved, must be filtered"},
+ {"topic_id": "unknown000000", "opener": "not in kept"},
+ ],
+ "suppressed": [],
+ }
+ _touch(_interests(ws, DAY), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
+ _write_state(ws, resolved=[{"id": id_b, "title": "已解决主题", "resolved_at": DAY, "evidence": ""}])
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
- response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
- assert [t["title"] for t in response.answer["topics"]] == ["议程主题"]
- assert response.answer["agenda"] == [{"topic_id": id_a, "opener": "对了,那件事可以动了"}]
-
- asyncio.run(run())
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
+ response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
+ assert [t["title"] for t in response.answer["topics"]] == ["议程主题"]
+ assert response.answer["agenda"] == [{"topic_id": id_a, "opener": "对了,那件事可以动了"}]
# ---------------------------------------------------------------------------
@@ -1867,120 +1787,112 @@ def test_strip_frontmatter_and_profile_loader(tmp_path):
assert load_personal_profile_block(empty_ws, "digest", 0, "profile.md") == "(no user profile)"
-def test_chain_with_configured_daily_dir(tmp_path):
+@pytest.mark.asyncio
+async def test_chain_with_configured_daily_dir(tmp_path):
"""The full chain honours daily_dir=memory (paths, truth source, prompts)."""
- async def run():
- ws = tmp_path
- _touch(ws / "memory" / DAY / "session.md", "小米财报讨论")
- _touch(
- ws / "digest" / "personal" / "user-profile.md",
- "---\ndescription: 张三画像\nname: user-profile\n---\n\n张三关注小米与中概股。\n",
- )
- extract_reply = _reply(
- follow_ups=[_topic("小米Q2财报跟进", confidence=0.9, paths=[f"memory/{DAY}/session.md"])],
- )
- fid = topic_id("小米Q2财报跟进")
- plan_reply = _plan_reply([_card(fid)])
- agenda_text = _agenda_reply([{"topic_id": fid, "order_reason": "only candidate"}], [])
+ ws = tmp_path
+ _touch(ws / "memory" / DAY / "session.md", "小米财报讨论")
+ _touch(
+ ws / "digest" / "personal" / "user-profile.md",
+ "---\ndescription: 张三画像\nname: user-profile\n---\n\n张三关注小米与中概股。\n",
+ )
+ extract_reply = _reply(
+ follow_ups=[_topic("小米Q2财报跟进", confidence=0.9, paths=[f"memory/{DAY}/session.md"])],
+ )
+ fid = topic_id("小米Q2财报跟进")
+ plan_reply = _plan_reply([_card(fid)])
+ agenda_text = _agenda_reply([{"topic_id": fid, "order_reason": "only candidate"}], [])
- app = ApplicationContext(workspace_dir=str(ws), daily_dir="memory", digest_dir="digest")
- wrapper = _AgentWrapper([extract_reply, plan_reply, agenda_text])
- catalog = _Catalog()
- context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
- extract = ProactiveExtractStep(app_context=app)
- resp_extract = await extract(context)
- topics = ProactiveTopicsStep(app_context=app)
- resp_topics = await topics(context)
- plan = ProactivePlanStep(app_context=app)
- resp_plan = await plan(context)
- agenda = ProactiveAgendaStep(app_context=app)
- resp_agenda = await agenda(context)
- finish = ProactiveFinishStep(app_context=app)
- resp_finish = await finish(context)
- assert all(r.success for r in (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish))
+ app = ApplicationContext(workspace_dir=str(ws), daily_dir="memory", digest_dir="digest")
+ wrapper = _AgentWrapper([extract_reply, plan_reply, agenda_text])
+ catalog = _Catalog()
+ context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
+ extract = ProactiveExtractStep(app_context=app)
+ resp_extract = await extract(context)
+ topics = ProactiveTopicsStep(app_context=app)
+ resp_topics = await topics(context)
+ plan = ProactivePlanStep(app_context=app)
+ resp_plan = await plan(context)
+ agenda = ProactiveAgendaStep(app_context=app)
+ resp_agenda = await agenda(context)
+ finish = ProactiveFinishStep(app_context=app)
+ resp_finish = await finish(context)
+ assert all(r.success for r in (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish))
- state = context.get("proactive")
- assert state["daily_dir"] == "memory"
- assert state["changed_paths"] == [f"memory/{DAY}/session.md"]
- # truth source + exposure land under memory/, not daily/
- assert (ws / "memory" / "_proactive.yaml").is_file()
- data = yaml.safe_load((ws / "memory" / DAY / "interests.yaml").read_text(encoding="utf-8"))
- assert data["push"] is True
- assert [t["id"] for t in data["topics"]] == [fid]
- assert not (ws / "daily").exists()
- # profile + configured daily dir reached the prompts
- assert any("张三画像" in inp or "张三关注小米" in inp for inp in wrapper.last_inputs)
- assert any(f"memory/{DAY}" in inp for inp in wrapper.last_inputs)
- assert any("memory/" in sp for sp in wrapper.last_system_prompts)
-
- asyncio.run(run())
+ state = context.get("proactive")
+ assert state["daily_dir"] == "memory"
+ assert state["changed_paths"] == [f"memory/{DAY}/session.md"]
+ # truth source + exposure land under memory/, not daily/
+ assert (ws / "memory" / "_proactive.yaml").is_file()
+ data = yaml.safe_load((ws / "memory" / DAY / "interests.yaml").read_text(encoding="utf-8"))
+ assert data["push"] is True
+ assert [t["id"] for t in data["topics"]] == [fid]
+ assert not (ws / "daily").exists()
+ # profile + configured daily dir reached the prompts
+ assert any("张三画像" in inp or "张三关注小米" in inp for inp in wrapper.last_inputs)
+ assert any(f"memory/{DAY}" in inp for inp in wrapper.last_inputs)
+ assert any("memory/" in sp for sp in wrapper.last_system_prompts)
-def test_finish_defers_paths_modified_during_round(tmp_path):
+@pytest.mark.asyncio
+async def test_finish_defers_paths_modified_during_round(tmp_path):
"""Files edited while the LLM calls run are left for the next round (audit 3)."""
- async def run():
- ws = tmp_path
- _touch(ws / "daily" / DAY / "a.md", "first note")
- note_b = _touch(ws / "daily" / DAY / "b.md", "second note")
- reply = _reply(follow_ups=[], extends=[])
- app = ApplicationContext(workspace_dir=str(ws))
- wrapper = _AgentWrapper([reply])
- catalog = _Catalog()
- context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
+ ws = tmp_path
+ _touch(ws / "daily" / DAY / "a.md", "first note")
+ note_b = _touch(ws / "daily" / DAY / "b.md", "second note")
+ reply = _reply(follow_ups=[], extends=[])
+ app = ApplicationContext(workspace_dir=str(ws))
+ wrapper = _AgentWrapper([reply])
+ catalog = _Catalog()
+ context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
- resp_extract = await ProactiveExtractStep(app_context=app)(context)
- assert resp_extract.success is True
- state = context.get("proactive")
- assert set(state["changed_paths"]) == {f"daily/{DAY}/a.md", f"daily/{DAY}/b.md"}
- assert state["changed_mtimes"][f"daily/{DAY}/b.md"] == note_b.stat().st_mtime
+ resp_extract = await ProactiveExtractStep(app_context=app)(context)
+ assert resp_extract.success is True
+ state = context.get("proactive")
+ assert set(state["changed_paths"]) == {f"daily/{DAY}/a.md", f"daily/{DAY}/b.md"}
+ assert state["changed_mtimes"][f"daily/{DAY}/b.md"] == note_b.stat().st_mtime
- # The user edits b.md while plan/agenda LLM calls are still running.
- note_b.write_text("second note, edited mid-round", encoding="utf-8")
- future = time.time() + 5
- os.utime(note_b, (future, future))
+ # The user edits b.md while plan/agenda LLM calls are still running.
+ note_b.write_text("second note, edited mid-round", encoding="utf-8")
+ future = time.time() + 5
+ os.utime(note_b, (future, future))
- resp_finish = await ProactiveFinishStep(app_context=app)(context)
- assert resp_finish.success is True
- final_state = context.get("proactive")
- assert final_state["checkpoint_paths"] == [f"daily/{DAY}/a.md"]
- assert "deferred 1" in resp_finish.answer
- assert {n.path for n in catalog.upserts} == {f"daily/{DAY}/a.md"}
-
- asyncio.run(run())
+ resp_finish = await ProactiveFinishStep(app_context=app)(context)
+ assert resp_finish.success is True
+ final_state = context.get("proactive")
+ assert final_state["checkpoint_paths"] == [f"daily/{DAY}/a.md"]
+ assert "deferred 1" in resp_finish.answer
+ assert {n.path for n in catalog.upserts} == {f"daily/{DAY}/a.md"}
-def test_read_single_corrupt_yaml_is_read_only_failure(tmp_path):
+@pytest.mark.asyncio
+async def test_read_single_corrupt_yaml_is_read_only_failure(tmp_path):
"""Corrupt interests.yaml: the read fails loudly and never moves the file (audit 2)."""
- async def run():
- ws = tmp_path
- corrupt = "topics: [unclosed"
- path = _touch(ws / "daily" / DAY / "interests.yaml", corrupt)
- step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
- response = await step(RuntimeContext(date=DAY, file_store=_FileStore(ws)))
- assert response.success is False
- assert response.metadata.get("error")
- assert path.is_file()
- assert path.read_text(encoding="utf-8") == corrupt
- assert not list(path.parent.glob("interests.corrupt-*"))
-
- asyncio.run(run())
+ ws = tmp_path
+ corrupt = "topics: [unclosed"
+ path = _touch(ws / "daily" / DAY / "interests.yaml", corrupt)
+ step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
+ response = await step(RuntimeContext(date=DAY, file_store=_FileStore(ws)))
+ assert response.success is False
+ assert response.metadata.get("error")
+ assert path.is_file()
+ assert path.read_text(encoding="utf-8") == corrupt
+ assert not list(path.parent.glob("interests.corrupt-*"))
-def test_carry_forward_expiry_boundary_consistent(tmp_path):
+@pytest.mark.asyncio
+async def test_carry_forward_expiry_boundary_consistent(tmp_path):
"""Age == carry_forward_days is over-age in both prompt loading and trim (audit 9)."""
- async def run():
- ws = tmp_path
- edge_day = (dt.date.fromisoformat(DAY) - dt.timedelta(days=14)).isoformat()
- _write_state(ws, open_topics=[_state_topic("Edge Topic", day=edge_day)])
- state_file, _ = load_state(ws)
- carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
- assert carry_all == []
+ ws = tmp_path
+ edge_day = (dt.date.fromisoformat(DAY) - dt.timedelta(days=14)).isoformat()
+ _write_state(ws, open_topics=[_state_topic("Edge Topic", day=edge_day)])
+ state_file, _ = load_state(ws)
+ carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
+ assert carry_all == []
- trim_state_file(state_file, DAY, 14)
- assert state_file.open_topics == []
-
- asyncio.run(run())
+ trim_state_file(state_file, DAY, 14)
+ assert state_file.open_topics == []
diff --git a/tests/unit/test_prompt_handler.py b/tests/unit/test_prompt_handler.py
index a07cd2c6..89c92822 100644
--- a/tests/unit/test_prompt_handler.py
+++ b/tests/unit/test_prompt_handler.py
@@ -262,39 +262,3 @@ def test_repr():
r = repr(ph)
assert "en" in r
assert "2" in r
-
-
-if __name__ == "__main__":
- print("\n=== PromptHandler Tests ===")
- test_init_filters_non_string_values()
- test_load_prompt_dict_basic()
- test_load_prompt_dict_skips_non_string_values()
- test_load_prompt_dict_overwrite_true()
- test_load_prompt_dict_overwrite_false()
- test_load_prompt_dict_none()
- test_load_prompt_dict_non_dict()
- test_load_prompt_by_file_yaml()
- test_load_prompt_by_file_json()
- test_load_prompt_by_file_none()
- test_load_prompt_by_file_nonexistent()
- test_load_prompt_by_file_unsupported_extension()
- test_get_prompt_bare_key()
- test_get_prompt_strips()
- test_get_prompt_missing_raises()
- test_get_prompt_language_fallback()
- test_get_prompt_language_fallback_to_bare()
- test_has_prompt()
- test_has_prompt_with_language()
- test_list_prompts_all()
- test_list_prompts_filtered()
- test_flag_filter_keeps_matching()
- test_flag_filter_removes_non_matching()
- test_flag_filter_default_false()
- test_flag_filter_unflagged_lines_always_kept()
- test_format_variables()
- test_format_missing_variable_raises()
- test_format_no_variables_no_error()
- test_format_flags_and_variables_combined()
- test_format_flags_false_variable_not_needed()
- test_repr()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_read_image_steps.py b/tests/unit/test_read_image_steps.py
index 36214205..fce6b50c 100644
--- a/tests/unit/test_read_image_steps.py
+++ b/tests/unit/test_read_image_steps.py
@@ -13,13 +13,14 @@ Coverage:
# pylint: disable=protected-access
-import asyncio
+from contextlib import chdir as temp_chdir
import base64
-import os
import tempfile
import warnings
from pathlib import Path
+import pytest
+
from reme.components.file_store import LocalFileStore
from reme.steps.file_io import read_image as crud_read_image
@@ -27,26 +28,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
-def _run(coro):
- asyncio.run(coro)
-
-
async def _make_store() -> LocalFileStore:
store = LocalFileStore(name="t_img", embedding_store="")
await store.start()
@@ -68,166 +49,139 @@ async def _read_image(store: LocalFileStore, *, step_kwargs: dict | None = None,
return step.context.response
-def test_read_image_png():
+@pytest.mark.asyncio
+async def test_read_image_png():
"""``read_image path=img/cat.png`` returns base64 + ``image/png`` mime."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- payload = b"\x89PNG\r\n\x1a\n" + b"fake-png-body-bytes"
- _seed_bytes("img/cat.png", payload)
- store = await _make_store()
- resp = await _read_image(store, path="img/cat.png")
- assert resp.success is True, resp
- assert base64.b64decode(resp.answer) == payload, "base64 round-trip mismatch"
- assert resp.metadata["mime"] == "image/png", resp.metadata
- assert resp.metadata["size_bytes"] == len(payload), resp.metadata
- assert "oversized" not in resp.metadata, resp.metadata
- await store.close()
- print("✓ test_read_image_png passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ payload = b"\x89PNG\r\n\x1a\n" + b"fake-png-body-bytes"
+ _seed_bytes("img/cat.png", payload)
+ store = await _make_store()
+ resp = await _read_image(store, path="img/cat.png")
+ assert resp.success is True, resp
+ assert base64.b64decode(resp.answer) == payload, "base64 round-trip mismatch"
+ assert resp.metadata["mime"] == "image/png", resp.metadata
+ assert resp.metadata["size_bytes"] == len(payload), resp.metadata
+ assert "oversized" not in resp.metadata, resp.metadata
+ await store.close()
-def test_read_image_jpeg():
+@pytest.mark.asyncio
+async def test_read_image_jpeg():
"""``.jpg`` suffix maps to ``image/jpeg``."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- payload = b"\xff\xd8\xff\xe0" + b"jpeg-body"
- _seed_bytes("dog.jpg", payload)
- store = await _make_store()
- resp = await _read_image(store, path="dog.jpg")
- assert resp.success is True, resp
- assert base64.b64decode(resp.answer) == payload
- assert resp.metadata["mime"] == "image/jpeg", resp.metadata
- await store.close()
- print("✓ test_read_image_jpeg passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ payload = b"\xff\xd8\xff\xe0" + b"jpeg-body"
+ _seed_bytes("dog.jpg", payload)
+ store = await _make_store()
+ resp = await _read_image(store, path="dog.jpg")
+ assert resp.success is True, resp
+ assert base64.b64decode(resp.answer) == payload
+ assert resp.metadata["mime"] == "image/jpeg", resp.metadata
+ await store.close()
-def test_read_image_oversized():
+@pytest.mark.asyncio
+async def test_read_image_oversized():
"""Above ``max_bytes`` → ``answer`` is a notice, ``metadata.oversized=True``."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- payload = b"\x89PNG\r\n\x1a\n" + b"x" * 2048
- _seed_bytes("big.png", payload)
- store = await _make_store()
- resp = await _read_image(store, step_kwargs={"max_bytes": 1024}, path="big.png")
- assert resp.success is True, resp
- assert resp.metadata["oversized"] is True, resp.metadata
- assert resp.metadata["max_bytes"] == 1024, resp.metadata
- assert resp.metadata["size_bytes"] == len(payload), resp.metadata
- assert resp.metadata["mime"] == "image/png", resp.metadata
- try:
- decoded = base64.b64decode(resp.answer, validate=True)
- assert decoded != payload, "oversized branch must not return real base64"
- except Exception:
- pass # expected — answer is notice text, not base64
- assert "exceeds max_bytes" in resp.answer
- await store.close()
- print("✓ test_read_image_oversized passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ payload = b"\x89PNG\r\n\x1a\n" + b"x" * 2048
+ _seed_bytes("big.png", payload)
+ store = await _make_store()
+ resp = await _read_image(store, step_kwargs={"max_bytes": 1024}, path="big.png")
+ assert resp.success is True, resp
+ assert resp.metadata["oversized"] is True, resp.metadata
+ assert resp.metadata["max_bytes"] == 1024, resp.metadata
+ assert resp.metadata["size_bytes"] == len(payload), resp.metadata
+ assert resp.metadata["mime"] == "image/png", resp.metadata
+ try:
+ decoded = base64.b64decode(resp.answer, validate=True)
+ assert decoded != payload, "oversized branch must not return real base64"
+ except Exception:
+ pass # expected — answer is notice text, not base64
+ assert "exceeds max_bytes" in resp.answer
+ await store.close()
-def test_read_image_unknown_suffix():
+@pytest.mark.asyncio
+async def test_read_image_unknown_suffix():
"""Unknown suffix → still returns base64, ``metadata.non_image_warning=True``."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- payload = b"any-bytes-here-for-blob"
- _seed_bytes("blob.xyz", payload)
- store = await _make_store()
- resp = await _read_image(store, path="blob.xyz")
- assert resp.success is True, resp
- assert base64.b64decode(resp.answer) == payload
- assert resp.metadata["non_image_warning"] is True, resp.metadata
- assert resp.metadata["mime"] is None, resp.metadata
- await store.close()
- print("✓ test_read_image_unknown_suffix passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ payload = b"any-bytes-here-for-blob"
+ _seed_bytes("blob.xyz", payload)
+ store = await _make_store()
+ resp = await _read_image(store, path="blob.xyz")
+ assert resp.success is True, resp
+ assert base64.b64decode(resp.answer) == payload
+ assert resp.metadata["non_image_warning"] is True, resp.metadata
+ assert resp.metadata["mime"] is None, resp.metadata
+ await store.close()
-def test_read_image_no_suffix():
+@pytest.mark.asyncio
+async def test_read_image_no_suffix():
"""No suffix → compatibility mode (no auto-append), still reads as base64."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- payload = b"\x89PNG\r\n\x1a\nbody"
- _seed_bytes("no_suffix_blob", payload)
- store = await _make_store()
- resp = await _read_image(store, path="no_suffix_blob")
- assert resp.success is True, resp
- assert resp.metadata["non_image_warning"] is True, resp.metadata
- assert resp.metadata["mime"] is None, resp.metadata
- assert base64.b64decode(resp.answer) == payload
- await store.close()
- print("✓ test_read_image_no_suffix passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ payload = b"\x89PNG\r\n\x1a\nbody"
+ _seed_bytes("no_suffix_blob", payload)
+ store = await _make_store()
+ resp = await _read_image(store, path="no_suffix_blob")
+ assert resp.success is True, resp
+ assert resp.metadata["non_image_warning"] is True, resp.metadata
+ assert resp.metadata["mime"] is None, resp.metadata
+ assert base64.b64decode(resp.answer) == payload
+ await store.close()
-def test_read_image_missing():
+@pytest.mark.asyncio
+async def test_read_image_missing():
"""Non-existent path → ``success=False`` with ``does not exist`` message."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _read_image(store, path="never_existed.png")
- assert resp.success is False, resp
- assert resp.answer.startswith("Error:"), resp
- assert "does not exist" in resp.answer
- await store.close()
- print("✓ test_read_image_missing passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _read_image(store, path="never_existed.png")
+ assert resp.success is False, resp
+ assert resp.answer.startswith("Error:"), resp
+ assert "does not exist" in resp.answer
+ await store.close()
-def test_read_image_is_directory():
+@pytest.mark.asyncio
+async def test_read_image_is_directory():
"""Path pointing to a directory → ``success=False`` with ``is not a file``."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- (Path(tmp) / "subdir").mkdir(parents=True, exist_ok=True)
- store = await _make_store()
- resp = await _read_image(store, path="subdir")
- assert resp.success is False, resp
- assert "is not a file" in resp.answer, resp
- await store.close()
- print("✓ test_read_image_is_directory passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ (Path(tmp) / "subdir").mkdir(parents=True, exist_ok=True)
+ store = await _make_store()
+ resp = await _read_image(store, path="subdir")
+ assert resp.success is False, resp
+ assert "is not a file" in resp.answer, resp
+ await store.close()
-def test_read_image_path_required():
+@pytest.mark.asyncio
+async def test_read_image_path_required():
"""Empty ``path`` → ``success=False`` with ``path is required`` message."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _read_image(store, path="")
- assert resp.success is False, resp
- assert "`path` is required" in resp.answer, resp
- await store.close()
- print("✓ test_read_image_path_required passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _read_image(store, path="")
+ assert resp.success is False, resp
+ assert "`path` is required" in resp.answer, resp
+ await store.close()
-def test_read_image_invalid_max_bytes():
+@pytest.mark.asyncio
+async def test_read_image_invalid_max_bytes():
"""``max_bytes=-1`` → ``success=False`` with positive-integer error."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- _seed_bytes("a.png", b"\x89PNG\r\n\x1a\nx")
- store = await _make_store()
- resp = await _read_image(store, step_kwargs={"max_bytes": -1}, path="a.png")
- assert resp.success is False, resp
- assert "positive integer" in resp.answer, resp
- await store.close()
- print("✓ test_read_image_invalid_max_bytes passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ _seed_bytes("a.png", b"\x89PNG\r\n\x1a\nx")
+ store = await _make_store()
+ resp = await _read_image(store, step_kwargs={"max_bytes": -1}, path="a.png")
+ assert resp.success is False, resp
+ assert "positive integer" in resp.answer, resp
+ await store.close()
diff --git a/tests/unit/test_read_with_neighbors.py b/tests/unit/test_read_with_neighbors.py
index 56b94f0c..da9ee7ca 100644
--- a/tests/unit/test_read_with_neighbors.py
+++ b/tests/unit/test_read_with_neighbors.py
@@ -13,12 +13,13 @@ so these tests focus on the *integration* layer:
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
import warnings
from pathlib import Path
+import pytest
+
from reme.components.file_store import LocalFileStore
from reme.schema import FileFrontMatter, FileNode
from reme.steps.file_io import read as crud_read
@@ -28,26 +29,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
-def _run(coro):
- asyncio.run(coro)
-
-
async def _store_with(files: dict[str, dict]) -> LocalFileStore:
"""LocalFileStore seeded with files + parsed wikilinks + optional frontmatter."""
store = LocalFileStore(name="t_read_neighbors", embedding_store="")
@@ -86,118 +67,104 @@ async def _read(store: LocalFileStore, *, step_kwargs: dict | None = None, **cal
# -- off (default) -----------------------------------------------------------
-def test_read_without_neighbors_default():
+@pytest.mark.asyncio
+async def test_read_without_neighbors_default():
"""``with_neighbors`` defaults to off — no block, no metadata key."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc"},
- "B.md": {"body": "End node.", "name": "B Doc"},
- },
- )
- resp = await _read(store, path="A.md")
- assert resp.success is True
- assert "Related neighbors" not in str(resp.answer)
- assert "link_expansion" not in resp.metadata
- await store.close()
- print("✓ test_read_without_neighbors_default passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc"},
+ "B.md": {"body": "End node.", "name": "B Doc"},
+ },
+ )
+ resp = await _read(store, path="A.md")
+ assert resp.success is True
+ assert "Related neighbors" not in str(resp.answer)
+ assert "link_expansion" not in resp.metadata
+ await store.close()
# -- on + has neighbors ------------------------------------------------------
-def test_read_with_neighbors_injects_block_and_metadata():
+@pytest.mark.asyncio
+async def test_read_with_neighbors_injects_block_and_metadata():
"""Block is appended; metadata carries the raw expansion dict."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc", "description": "alpha"},
- "B.md": {"body": "End node.", "name": "B Doc", "description": "beta"},
- },
- )
- resp = await _read(store, step_kwargs={"with_neighbors": True}, path="A.md")
- assert resp.success is True
- answer = str(resp.answer)
- assert "Related neighbors" in answer
- assert "outlinks=1" in answer
- assert "inlinks=0" in answer
- assert "B.md" in answer
- assert 'name="B Doc"' in answer
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "A.md": {"body": "See [[B.md]] for details.", "name": "A Doc", "description": "alpha"},
+ "B.md": {"body": "End node.", "name": "B Doc", "description": "beta"},
+ },
+ )
+ resp = await _read(store, step_kwargs={"with_neighbors": True}, path="A.md")
+ assert resp.success is True
+ answer = str(resp.answer)
+ assert "Related neighbors" in answer
+ assert "outlinks=1" in answer
+ assert "inlinks=0" in answer
+ assert "B.md" in answer
+ assert 'name="B Doc"' in answer
- expansion = resp.metadata.get("link_expansion")
- assert expansion is not None
- assert "A.md" in expansion
- assert expansion["A.md"]["outlinks"][0]["path"] == "B.md"
- assert expansion["A.md"]["outlinks"][0]["meta"] == {"name": "B Doc", "description": "beta"}
- await store.close()
- print("✓ test_read_with_neighbors_injects_block_and_metadata passed")
-
- _run(run())
+ expansion = resp.metadata.get("link_expansion")
+ assert expansion is not None
+ assert "A.md" in expansion
+ assert expansion["A.md"]["outlinks"][0]["path"] == "B.md"
+ assert expansion["A.md"]["outlinks"][0]["meta"] == {"name": "B Doc", "description": "beta"}
+ await store.close()
-def test_read_with_neighbors_uses_posix_nested_path():
+@pytest.mark.asyncio
+async def test_read_with_neighbors_uses_posix_nested_path():
"""Nested graph keys use POSIX separators on every platform."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "notes/A.md": {"body": "See [[notes/B.md]].", "name": "A Doc"},
- "notes/B.md": {"body": "End node.", "name": "B Doc"},
- },
- )
- resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes/A.md")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "notes/A.md": {"body": "See [[notes/B.md]].", "name": "A Doc"},
+ "notes/B.md": {"body": "End node.", "name": "B Doc"},
+ },
+ )
+ resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes/A.md")
- expansion = resp.metadata.get("link_expansion")
- assert expansion is not None
- assert "notes/A.md" in expansion
- assert expansion["notes/A.md"]["outlinks"][0]["path"] == "notes/B.md"
- await store.close()
-
- _run(run())
+ expansion = resp.metadata.get("link_expansion")
+ assert expansion is not None
+ assert "notes/A.md" in expansion
+ assert expansion["notes/A.md"]["outlinks"][0]["path"] == "notes/B.md"
+ await store.close()
# -- on + zero neighbors -----------------------------------------------------
-def test_read_with_neighbors_no_links_no_block():
+@pytest.mark.asyncio
+async def test_read_with_neighbors_no_links_no_block():
"""File with no out/in links → no block appended, no metadata key."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with({"lonely.md": {"body": "no links here", "name": "Lonely"}})
- resp = await _read(store, step_kwargs={"with_neighbors": True}, path="lonely.md")
- assert resp.success is True
- assert "Related neighbors" not in str(resp.answer)
- assert "link_expansion" not in resp.metadata
- await store.close()
- print("✓ test_read_with_neighbors_no_links_no_block passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with({"lonely.md": {"body": "no links here", "name": "Lonely"}})
+ resp = await _read(store, step_kwargs={"with_neighbors": True}, path="lonely.md")
+ assert resp.success is True
+ assert "Related neighbors" not in str(resp.answer)
+ assert "link_expansion" not in resp.metadata
+ await store.close()
# -- on + non-md -------------------------------------------------------------
-def test_read_with_neighbors_non_md_falls_through():
+@pytest.mark.asyncio
+async def test_read_with_neighbors_non_md_falls_through():
"""Non-md target: neighbor injection is skipped even when ``with_neighbors=True``."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- (Path(tmp) / "notes.txt").write_text("plain text body", encoding="utf-8")
- store = LocalFileStore(name="t_read_neighbors_nonmd", embedding_store="")
- await store.start()
- resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes.txt")
- assert resp.success is True
- assert "Related neighbors" not in str(resp.answer)
- assert "link_expansion" not in resp.metadata
- await store.close()
- print("✓ test_read_with_neighbors_non_md_falls_through passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ (Path(tmp) / "notes.txt").write_text("plain text body", encoding="utf-8")
+ store = LocalFileStore(name="t_read_neighbors_nonmd", embedding_store="")
+ await store.start()
+ resp = await _read(store, step_kwargs={"with_neighbors": True}, path="notes.txt")
+ assert resp.success is True
+ assert "Related neighbors" not in str(resp.answer)
+ assert "link_expansion" not in resp.metadata
+ await store.close()
diff --git a/tests/unit/test_runtime_context.py b/tests/unit/test_runtime_context.py
index ec3df9fb..4ddceb07 100644
--- a/tests/unit/test_runtime_context.py
+++ b/tests/unit/test_runtime_context.py
@@ -156,25 +156,3 @@ def test_custom_response():
ctx = RuntimeContext(response=resp)
assert ctx.response is resp
assert ctx.response.success is False
-
-
-if __name__ == "__main__":
- print("\n=== RuntimeContext Tests ===")
- test_getitem_setitem()
- test_getitem_missing_raises()
- test_contains()
- test_delitem()
- test_get_with_default()
- test_update_merges_and_returns_self()
- test_from_context_creates_new_when_none()
- test_from_context_reuses_existing()
- test_apply_mapping_copies_values()
- test_apply_mapping_skips_missing_source()
- test_apply_mapping_empty_is_noop()
- test_stream_property()
- test_enqueue_raises_without_queue()
- test_add_stream_string()
- test_add_stream_done()
- test_default_response()
- test_custom_response()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_search_empty_response.py b/tests/unit/test_search_empty_response.py
new file mode 100644
index 00000000..179ccca8
--- /dev/null
+++ b/tests/unit/test_search_empty_response.py
@@ -0,0 +1,267 @@
+"""Empty search metadata explains missing results without changing answer compatibility."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import json
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from reme.components import ApplicationContext
+from reme.components.client.http_client import HttpClient
+from reme.components.file_store import BaseFileStore
+from reme.components.runtime_context import RuntimeContext
+from reme.components.service.cli_service import CliService
+from reme.schema import FileChunk
+from reme.steps.index import SearchStep
+from reme.steps.index._source_format import ALL_RETURNED_MESSAGE, NO_RESULTS_MESSAGE
+
+
+def _chunk(chunk_id="match", score=2.0):
+ """Create a scored hit with an embedding that must not appear in results."""
+ return FileChunk(
+ id=chunk_id,
+ path=f"daily/{chunk_id}.md",
+ text=f"memory {chunk_id}",
+ start_line=1,
+ end_line=2,
+ embedding=[0.1, 0.2],
+ scores={"score": score},
+ )
+
+
+def _store(chunks=(), mode="keyword"):
+ """Mock retrieval boundaries without starting indexes or model providers."""
+ store = Mock(spec=BaseFileStore)
+ store.vector_search = AsyncMock(return_value=list(chunks) if mode in {"vector", "hybrid"} else [])
+ store.keyword_search = AsyncMock(return_value=list(chunks) if mode in {"keyword", "hybrid"} else [])
+ return store
+
+
+def _step(tmp_path, store, *, app_context=None, vector_weight=0.0):
+ """Use an isolated application, sharing it explicitly for repeated calls."""
+ return SearchStep(
+ app_context=app_context if app_context is not None else ApplicationContext(workspace_dir=str(tmp_path)),
+ file_store=store,
+ vector_weight=vector_weight,
+ expand_links=False,
+ )
+
+
+@pytest.mark.parametrize(
+ "mode,weight,score,min_score",
+ [
+ ("keyword", 0.7, None, 0.0),
+ ("keyword", 0.0, 0.1, 1.0),
+ ("vector", 1.0, 0.1, 1.0),
+ ("hybrid", 0.7, 0.1, 1.0),
+ ],
+ ids=["no-matches", "keyword-below-threshold", "vector-below-threshold", "hybrid-below-threshold"],
+)
+@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
+def test_search_without_eligible_results_explains_empty_metadata(
+ tmp_path,
+ mode,
+ weight,
+ score,
+ min_score,
+ tool_context_id,
+):
+ """No matches and score-filtered hits share the same empty-result behavior."""
+ chunks = [] if score is None else [_chunk(score=score)]
+ response = asyncio.run(
+ _step(tmp_path, _store(chunks, mode), vector_weight=weight)(
+ RuntimeContext(
+ query="missing" if score is None else "memory",
+ min_score=min_score,
+ tool_context_id=tool_context_id,
+ ),
+ ),
+ )
+
+ assert response.success
+ assert response.answer == ""
+ assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
+ assert response.metadata["results"] == []
+ assert response.metadata["link_expansion"] == {}
+ assert response.metadata["counts"] == {
+ "vector": int(score is not None and mode in {"vector", "hybrid"}),
+ "keyword": int(score is not None and mode in {"keyword", "hybrid"}),
+ "returned": 0,
+ "hybrid": score is not None and mode == "hybrid",
+ }
+ if tool_context_id:
+ assert response.metadata["dedup"]["skipped_seen"] == 0
+ else:
+ assert "dedup" not in response.metadata
+
+
+def test_search_all_seen_explains_empty_metadata_across_step_instances(tmp_path):
+ """Fresh Step instances share previously returned content through the application."""
+
+ async def run():
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ store = _store([_chunk()])
+ first = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-1"),
+ )
+ second = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-1"),
+ )
+
+ assert first.metadata["counts"]["returned"] == 1
+ assert "empty_result" not in first.metadata
+ assert second.success
+ assert second.answer == ""
+ assert second.metadata["empty_result"] == {"reason": "all_returned", "message": ALL_RETURNED_MESSAGE}
+ assert second.metadata["results"] == []
+ assert second.metadata["link_expansion"] == {}
+ assert second.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 0, "hybrid": False}
+ assert second.metadata["dedup"]["seen_before"] == 1
+ assert second.metadata["dedup"]["skipped_seen"] == 1
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("min_score", [0.0, 3.0], ids=["no-matches", "below-threshold"])
+def test_search_history_does_not_turn_missing_results_into_all_returned(tmp_path, min_score):
+ """Past hits do not explain a new query with no eligible candidates."""
+
+ async def run():
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ store = _store([_chunk()])
+ first = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-1"),
+ )
+ assert first.metadata["counts"]["returned"] == 1
+ if not min_score:
+ store.keyword_search.return_value = []
+ response = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="another query", min_score=min_score, tool_context_id="ctx-1"),
+ )
+
+ assert response.success
+ assert response.answer == ""
+ assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
+ assert response.metadata["results"] == []
+ assert response.metadata["counts"]["returned"] == 0
+ assert response.metadata["dedup"]["seen_before"] == 1
+ assert response.metadata["dedup"]["skipped_seen"] == 0
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
+def test_search_hit_keeps_answer_and_structured_results(tmp_path, tool_context_id):
+ """A normal hit keeps its exact rendering and excludes the embedding from metadata."""
+ chunk = _chunk()
+ response = asyncio.run(
+ _step(tmp_path, _store([chunk]))(RuntimeContext(query="memory", tool_context_id=tool_context_id)),
+ )
+
+ assert response.success
+ assert "empty_result" not in response.metadata
+ assert response.answer == "========== daily/match.md:1-2 [score=2.0000] ==========\nmemory match"
+ assert response.metadata["results"] == [chunk.model_dump(exclude_none=True, exclude={"embedding"})]
+ assert response.metadata["link_expansion"] == {}
+ assert response.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 1, "hybrid": False}
+
+
+def test_search_partial_dedup_and_context_isolation_keep_hit_answers(tmp_path):
+ """Unseen content still renders normally and another context can retrieve seen content."""
+
+ async def run():
+ app_context = ApplicationContext(workspace_dir=str(tmp_path))
+ store = _store([_chunk()])
+ await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-1"),
+ )
+ store.keyword_search.return_value = [_chunk(), _chunk("new")]
+ response = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-1"),
+ )
+
+ assert response.success
+ assert "empty_result" not in response.metadata
+ assert response.answer == "========== daily/new.md:1-2 [score=2.0000] ==========\nmemory new"
+ assert [result["id"] for result in response.metadata["results"]] == ["new"]
+ assert response.metadata["dedup"]["skipped_seen"] == 1
+
+ other = await _step(tmp_path, store, app_context=app_context)(
+ RuntimeContext(query="memory", tool_context_id="ctx-2"),
+ )
+ assert [result["id"] for result in other.metadata["results"]] == ["match", "new"]
+ assert "memory match" in other.answer
+ assert "memory new" in other.answer
+ assert other.metadata["dedup"]["skipped_seen"] == 0
+ assert "empty_result" not in other.metadata
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("next_query", ["memory", ""], ids=["normal-hit", "invalid-query"])
+def test_search_reused_response_does_not_keep_empty_reason(tmp_path, next_query):
+ """A later hit or error must not inherit an earlier empty search explanation."""
+
+ async def run():
+ store = _store()
+ step = _step(tmp_path, store)
+ context = RuntimeContext(query="missing")
+ context.response.metadata["other_step"] = "preserved"
+ first = await step(context)
+ assert first.answer == ""
+ assert first.metadata["empty_result"]["reason"] == "no_results"
+
+ store.keyword_search.return_value = [_chunk()]
+ context.update({"query": next_query})
+ response = await step(context)
+
+ assert "empty_result" not in response.metadata
+ assert response.metadata["other_step"] == "preserved"
+ if next_query:
+ assert response.success
+ assert "memory match" in response.answer
+ assert response.metadata["counts"]["returned"] == 1
+ else:
+ assert not response.success
+ assert response.answer == "Error: query cannot be empty"
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("all_returned", [False, True], ids=["no-results", "all-returned"])
+@pytest.mark.parametrize("show_metadata", [False, True], ids=["default-display", "show-metadata"])
+def test_empty_search_explanation_uses_existing_cli_metadata_display(tmp_path, capsys, all_returned, show_metadata):
+ """Both CLI paths expose reasons only when metadata display is requested."""
+
+ async def search():
+ store = _store([_chunk()] if all_returned else [])
+ step = _step(tmp_path, store)
+ if all_returned:
+ await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
+ return await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
+
+ response = asyncio.run(search())
+ expected = {
+ "reason": "all_returned" if all_returned else "no_results",
+ "message": ALL_RETURNED_MESSAGE if all_returned else NO_RESULTS_MESSAGE,
+ }
+ assert response.answer == ""
+ assert response.success
+ assert response.metadata["empty_result"] == expected
+
+ http_text = HttpClient(show_metadata=show_metadata)._format_for_display(response.model_dump_json())
+ app = Mock(start=AsyncMock(), run_job=AsyncMock(return_value=response), close=AsyncMock())
+ CliService(job="search", job_args={"query": "memory"}, show_metadata=show_metadata).start_service(app)
+ output = capsys.readouterr()
+
+ assert output.err == ""
+ app.run_job.assert_awaited_once_with("search", query="memory")
+ app.close.assert_awaited_once()
+ if show_metadata:
+ assert json.loads(http_text.removeprefix("\n✅ "))["empty_result"] == expected
+ assert json.loads(output.out)["empty_result"] == expected
+ else:
+ assert http_text == "\n✅"
+ assert output.out == "\n"
diff --git a/tests/unit/test_search_step.py b/tests/unit/test_search_step.py
index 886ff67d..851c6694 100644
--- a/tests/unit/test_search_step.py
+++ b/tests/unit/test_search_step.py
@@ -41,6 +41,8 @@ def _load_search_v2_step(plugin: str) -> type:
SearchV2Step = _load_search_v2_step("beam")
LmeSearchV2Step = _load_search_v2_step("lme")
+_HYBRID_SEARCH_STEPS = [pytest.param(SearchStep, id="core"), pytest.param(SearchV2Step, id="beam")]
+
class FakeSearchStore(BaseFileStore):
"""Minimal file_store for SearchV2Step: static search results and empty graph links."""
@@ -168,294 +170,282 @@ def _chunk(
)
-def test_search_v2_step_rrf_merges_vector_and_keyword_by_chunk_id():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_rrf_merges_vector_and_keyword_by_chunk_id(step_cls):
"""Hybrid search fuses same-id hits once and keeps per-branch scores in metadata."""
- async def run():
- shared_v = _chunk("shared", "daily/a.md", "shared vector text", "vector", 0.92, line=3)
- vector_only = _chunk("vector-only", "daily/b.md", "vector text", "vector", 0.71)
- keyword_only = _chunk("keyword-only", "digest/c.md", "keyword text", "keyword", 8.0)
- shared_k = _chunk("shared", "daily/a.md", "shared keyword text", "keyword", 7.0, line=3)
- store = FakeSearchStore(
- vector_results=[shared_v, vector_only],
- keyword_results=[keyword_only, shared_k],
- )
- step = SearchV2Step(file_store=store, vector_weight=0.5, candidate_multiplier=2, expand_links=False)
- ctx = RuntimeContext(query="alpha", limit=3, search_filter={"path_prefix": "daily/"})
+ shared_v = _chunk("shared", "daily/a.md", "shared vector text", "vector", 0.92, line=3)
+ vector_only = _chunk("vector-only", "daily/b.md", "vector text", "vector", 0.71)
+ keyword_only = _chunk("keyword-only", "digest/c.md", "keyword text", "keyword", 8.0)
+ shared_k = _chunk("shared", "daily/a.md", "shared keyword text", "keyword", 7.0, line=3)
+ store = FakeSearchStore(
+ vector_results=[shared_v, vector_only],
+ keyword_results=[keyword_only, shared_k],
+ )
+ step = step_cls(file_store=store, vector_weight=0.5, candidate_multiplier=2, expand_links=False)
+ ctx = RuntimeContext(query="alpha", limit=3, search_filter={"path_prefix": "daily/"})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert resp.success is True
- assert resp.metadata["counts"] == {"vector": 2, "keyword": 2, "returned": 3, "hybrid": True}
- assert [r["id"] for r in resp.metadata["results"]] == ["shared", "keyword-only", "vector-only"]
- shared = resp.metadata["results"][0]
- assert shared["scores"]["vector"] == 0.92
- assert shared["scores"]["keyword"] == 7.0
- assert shared["scores"]["score"] > resp.metadata["results"][1]["scores"]["score"]
- assert "daily/a.md:3-3" in resp.answer
- assert "vector=0.9200" in resp.answer
- assert "keyword=7.0000" in resp.answer
- assert {call[0] for call in store.calls} == {"vector", "keyword"}
- assert all(call[2] == 6 for call in store.calls)
- assert all(call[3] == {"path_prefix": "daily/"} for call in store.calls)
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["counts"] == {"vector": 2, "keyword": 2, "returned": 3, "hybrid": True}
+ assert [r["id"] for r in resp.metadata["results"]] == ["shared", "keyword-only", "vector-only"]
+ shared = resp.metadata["results"][0]
+ assert shared["scores"]["vector"] == 0.92
+ assert shared["scores"]["keyword"] == 7.0
+ assert shared["scores"]["score"] > resp.metadata["results"][1]["scores"]["score"]
+ assert "daily/a.md:3-3" in resp.answer
+ assert "vector=0.9200" in resp.answer
+ assert "keyword=7.0000" in resp.answer
+ assert {call[0] for call in store.calls} == {"vector", "keyword"}
+ assert all(call[2] == 6 for call in store.calls)
+ assert all(call[3] == {"path_prefix": "daily/"} for call in store.calls)
-def test_lme_plain_search_steps_use_ten_times_limit_candidates():
+@pytest.mark.asyncio
+async def test_lme_plain_search_steps_use_ten_times_limit_candidates():
"""LME vector/bm25 tools fetch a wider candidate pool before truncating."""
- async def run():
- store = FakeSearchStore()
+ store = FakeSearchStore()
- vector = VectorSearchStep(file_store=store, include_source=False)
- bm25 = Bm25SearchStep(file_store=store, include_source=False)
+ vector = VectorSearchStep(file_store=store, include_source=False)
+ bm25 = Bm25SearchStep(file_store=store, include_source=False)
- await vector(RuntimeContext(query="alpha", limit=3))
- await bm25(RuntimeContext(query="alpha", limit=3))
+ await vector(RuntimeContext(query="alpha", limit=3))
+ await bm25(RuntimeContext(query="alpha", limit=3))
- assert store.calls == [
- ("vector", "alpha", 30, {}),
- ("keyword", "alpha", 30, {}),
- ]
-
- asyncio.run(run())
+ assert store.calls == [
+ ("vector", "alpha", 30, {}),
+ ("keyword", "alpha", 30, {}),
+ ]
-def test_draft_steps_accumulate_by_tool_context_id():
+@pytest.mark.asyncio
+async def test_draft_steps_accumulate_by_tool_context_id():
"""Drafts are stored in app metadata and isolated by injected tool_context_id."""
- async def run():
- app_context = ApplicationContext()
- add = AddDraftStep(app_context=app_context)
- read = ReadAllDraftStep(app_context=app_context)
+ app_context = ApplicationContext()
+ add = AddDraftStep(app_context=app_context)
+ read = ReadAllDraftStep(app_context=app_context)
- await add(RuntimeContext(text="first", tool_context_id="ctx-1"))
- await add(RuntimeContext(text="second", tool_context_id="ctx-1"))
- await add(RuntimeContext(text="other", tool_context_id="ctx-2"))
+ await add(RuntimeContext(text="first", tool_context_id="ctx-1"))
+ await add(RuntimeContext(text="second", tool_context_id="ctx-1"))
+ await add(RuntimeContext(text="other", tool_context_id="ctx-2"))
- resp = await read(RuntimeContext(tool_context_id="ctx-1"))
+ resp = await read(RuntimeContext(tool_context_id="ctx-1"))
- assert resp.answer == "first\nsecond"
- assert resp.metadata["draft_count"] == 2
-
- asyncio.run(run())
+ assert resp.answer == "first\nsecond"
+ assert resp.metadata["draft_count"] == 2
-def test_search_v2_step_keyword_only_uses_keyword_scores_and_min_score():
- """When vector has no hits, SearchV2Step returns keyword results directly and applies min_score."""
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_keyword_only_uses_keyword_scores_and_min_score(step_cls):
+ """When vector has no hits, step_cls returns keyword results directly and applies min_score."""
- async def run():
- high = _chunk("high", "daily/high.md", "strong keyword hit", "keyword", 4.0)
- low = _chunk("low", "daily/low.md", "weak keyword hit", "keyword", 0.2)
- store = FakeSearchStore(keyword_results=[high, low])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(query="keyword", limit=5, min_score=1.0)
+ high = _chunk("high", "daily/high.md", "strong keyword hit", "keyword", 4.0)
+ low = _chunk("low", "daily/low.md", "weak keyword hit", "keyword", 0.2)
+ store = FakeSearchStore(keyword_results=[high, low])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(query="keyword", limit=5, min_score=1.0)
- resp = await step(ctx)
+ resp = await step(ctx)
- assert resp.metadata["counts"] == {"vector": 0, "keyword": 2, "returned": 1, "hybrid": False}
- assert [r["id"] for r in resp.metadata["results"]] == ["high"]
- assert "keyword=4.0000" not in resp.answer
- assert "score=4.0000" in resp.answer
- assert "daily/low.md" not in resp.answer
-
- asyncio.run(run())
+ assert resp.metadata["counts"] == {"vector": 0, "keyword": 2, "returned": 1, "hybrid": False}
+ assert [r["id"] for r in resp.metadata["results"]] == ["high"]
+ assert "keyword=4.0000" not in resp.answer
+ assert "score=4.0000" in resp.answer
+ assert "daily/low.md" not in resp.answer
-def test_plain_search_steps_apply_min_score_before_truncation():
+@pytest.mark.asyncio
+async def test_plain_search_steps_apply_min_score_before_truncation():
"""Vector-only and BM25-only tools should not return hits below ``min_score``."""
- async def run():
- vector_store = FakeSearchStore(
- vector_results=[
- _chunk("vector-high", "daily/high.md", "strong vector hit", "vector", 0.9),
- _chunk("vector-low", "daily/low.md", "weak vector hit", "vector", 0.2),
- ],
- )
- keyword_store = FakeSearchStore(
- keyword_results=[
- _chunk("keyword-high", "daily/high.md", "strong keyword hit", "keyword", 4.0),
- _chunk("keyword-low", "daily/low.md", "weak keyword hit", "keyword", 0.2),
- ],
- )
+ vector_store = FakeSearchStore(
+ vector_results=[
+ _chunk("vector-high", "daily/high.md", "strong vector hit", "vector", 0.9),
+ _chunk("vector-low", "daily/low.md", "weak vector hit", "vector", 0.2),
+ ],
+ )
+ keyword_store = FakeSearchStore(
+ keyword_results=[
+ _chunk("keyword-high", "daily/high.md", "strong keyword hit", "keyword", 4.0),
+ _chunk("keyword-low", "daily/low.md", "weak keyword hit", "keyword", 0.2),
+ ],
+ )
- vector = await VectorSearchStep(file_store=vector_store, include_source=False)(
- RuntimeContext(query="alpha", limit=5, min_score=0.5),
- )
- keyword = await Bm25SearchStep(file_store=keyword_store, include_source=False)(
- RuntimeContext(query="alpha", limit=5, min_score=1.0),
- )
+ vector = await VectorSearchStep(file_store=vector_store, include_source=False)(
+ RuntimeContext(query="alpha", limit=5, min_score=0.5),
+ )
+ keyword = await Bm25SearchStep(file_store=keyword_store, include_source=False)(
+ RuntimeContext(query="alpha", limit=5, min_score=1.0),
+ )
- assert [result["id"] for result in vector.metadata["results"]] == ["vector-high"]
- assert [result["id"] for result in keyword.metadata["results"]] == ["keyword-high"]
- assert vector.answer == "strong vector hit"
- assert keyword.answer == "strong keyword hit"
-
- asyncio.run(run())
+ assert [result["id"] for result in vector.metadata["results"]] == ["vector-high"]
+ assert [result["id"] for result in keyword.metadata["results"]] == ["keyword-high"]
+ assert vector.answer == "strong vector hit"
+ assert keyword.answer == "strong keyword hit"
-def test_search_v2_step_tool_context_deduplicates_returned_chunks_only():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_tool_context_deduplicates_returned_chunks_only(step_cls):
"""When tool_context_id is supplied, repeated searches skip previously returned chunks."""
- async def run():
- chunks = [
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- _chunk("c", "daily/c.md", "third", "keyword", 3.0),
- ]
- store = FakeSearchStore(keyword_results=chunks)
- step = SearchV2Step(file_store=store, expand_links=False)
+ chunks = [
+ _chunk("a", "daily/a.md", "first", "keyword", 5.0),
+ _chunk("b", "daily/b.md", "second", "keyword", 4.0),
+ _chunk("c", "daily/c.md", "third", "keyword", 3.0),
+ ]
+ store = FakeSearchStore(keyword_results=chunks)
+ step = step_cls(file_store=store, expand_links=False)
- first = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
- second = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
- third = await step(RuntimeContext(query="alpha", limit=2))
+ first = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
+ second = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
+ third = await step(RuntimeContext(query="alpha", limit=2))
- assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
- assert first.metadata["dedup"] == {
- "tool_context_id": "ctx-1",
- "seen_before": 0,
- "skipped_seen": 0,
- "seen_after": 2,
- "expired": 0,
- "ttl_seconds": 86400.0,
- }
- assert [r["id"] for r in second.metadata["results"]] == ["c"]
- assert second.metadata["dedup"]["seen_before"] == 2
- assert second.metadata["dedup"]["skipped_seen"] == 2
- assert second.metadata["dedup"]["seen_after"] == 3
- assert [r["id"] for r in third.metadata["results"]] == ["a", "b"]
- assert "dedup" not in third.metadata
-
- asyncio.run(run())
+ assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
+ assert first.metadata["dedup"] == {
+ "tool_context_id": "ctx-1",
+ "seen_before": 0,
+ "skipped_seen": 0,
+ "seen_after": 2,
+ "expired": 0,
+ "ttl_seconds": 86400.0,
+ }
+ assert [r["id"] for r in second.metadata["results"]] == ["c"]
+ assert second.metadata["dedup"]["seen_before"] == 2
+ assert second.metadata["dedup"]["skipped_seen"] == 2
+ assert second.metadata["dedup"]["seen_after"] == 3
+ assert [r["id"] for r in third.metadata["results"]] == ["a", "b"]
+ assert "dedup" not in third.metadata
-def test_search_v2_step_passes_metadata_filter_to_store():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_passes_metadata_filter_to_store(step_cls):
"""Search filters can target chunk metadata such as conversation_date."""
- async def run():
- hit = _chunk("hit", "daily/2023-01-19/event.md", "historical hit", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- search_filter = {"metadata": {"conversation_date": "2023-01-19"}}
+ hit = _chunk("hit", "daily/2023-01-19/event.md", "historical hit", "keyword", 3.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ search_filter = {"metadata": {"conversation_date": "2023-01-19"}}
- resp = await step(RuntimeContext(query="Jon job", limit=5, search_filter=search_filter))
+ resp = await step(RuntimeContext(query="Jon job", limit=5, search_filter=search_filter))
- assert resp.success is True
- assert resp.metadata["results"][0]["id"] == "hit"
- assert all(call[3] == search_filter for call in store.calls)
-
- asyncio.run(run())
+ assert resp.success is True
+ assert resp.metadata["results"][0]["id"] == "hit"
+ assert all(call[3] == search_filter for call in store.calls)
-def test_search_v2_step_tool_context_seen_chunks_expire_after_ttl():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_tool_context_seen_chunks_expire_after_ttl(step_cls):
"""Seen chunk ids under a tool_context_id are reusable after the configured TTL."""
- async def run():
- now = 1000.0
- chunks = [
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- ]
- store = FakeSearchStore(keyword_results=chunks)
- step = SearchV2Step(
- file_store=store,
- expand_links=False,
- seen_ttl_hours=1,
- clock=lambda: now,
- )
+ now = 1000.0
+ chunks = [
+ _chunk("a", "daily/a.md", "first", "keyword", 5.0),
+ _chunk("b", "daily/b.md", "second", "keyword", 4.0),
+ ]
+ store = FakeSearchStore(keyword_results=chunks)
+ step = step_cls(
+ file_store=store,
+ expand_links=False,
+ seen_ttl_hours=1,
+ clock=lambda: now,
+ )
- first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- now = 4601.0
- second = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
+ first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
+ now = 4601.0
+ second = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- assert [r["id"] for r in first.metadata["results"]] == ["a"]
- assert [r["id"] for r in second.metadata["results"]] == ["a"]
- assert second.metadata["dedup"]["expired"] == 1
- assert second.metadata["dedup"]["seen_before"] == 0
- assert second.metadata["dedup"]["ttl_seconds"] == 3600.0
-
- asyncio.run(run())
+ assert [r["id"] for r in first.metadata["results"]] == ["a"]
+ assert [r["id"] for r in second.metadata["results"]] == ["a"]
+ assert second.metadata["dedup"]["expired"] == 1
+ assert second.metadata["dedup"]["seen_before"] == 0
+ assert second.metadata["dedup"]["ttl_seconds"] == 3600.0
-def test_search_v2_step_tool_context_dedup_uses_subset_matching():
+@pytest.mark.asyncio
+async def test_search_v2_step_tool_context_dedup_uses_subset_matching():
"""A chunk is skipped only when its line range is a subset of a seen entry.
Partial overlap (straddle/superset) and different paths are NOT skipped:
the chunk carries lines not yet returned.
"""
- async def run():
- app_context = ApplicationContext()
- wide = FileChunk(
- id="wide",
+ app_context = ApplicationContext()
+ wide = FileChunk(
+ id="wide",
+ path="daily/a.md",
+ text="wide",
+ start_line=1,
+ end_line=20,
+ scores={"keyword": 5.0, "score": 5.0},
+ )
+ store = FakeSearchStore(keyword_results=[wide])
+ step = SearchV2Step(
+ file_store=store,
+ app_context=app_context,
+ expand_links=False,
+ )
+
+ first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
+ assert [r["id"] for r in first.metadata["results"]] == ["wide"]
+
+ # subset (5-10) -> subset of (1,20) -> skipped
+ # straddle (15-30) -> not a subset (30>20) -> kept
+ # far (40-50) -> not a subset -> kept
+ # other (b.md 1-5) -> different path -> kept
+ store.keyword_results = [
+ FileChunk(
+ id="subset",
path="daily/a.md",
- text="wide",
+ text="subset",
+ start_line=5,
+ end_line=10,
+ scores={"keyword": 4.0, "score": 4.0},
+ ),
+ FileChunk(
+ id="straddle",
+ path="daily/a.md",
+ text="straddle",
+ start_line=15,
+ end_line=30,
+ scores={"keyword": 3.0, "score": 3.0},
+ ),
+ FileChunk(
+ id="far",
+ path="daily/a.md",
+ text="far",
+ start_line=40,
+ end_line=50,
+ scores={"keyword": 2.0, "score": 2.0},
+ ),
+ FileChunk(
+ id="other",
+ path="daily/b.md",
+ text="other",
start_line=1,
- end_line=20,
- scores={"keyword": 5.0, "score": 5.0},
- )
- store = FakeSearchStore(keyword_results=[wide])
- step = SearchV2Step(
- file_store=store,
- app_context=app_context,
- expand_links=False,
- )
+ end_line=5,
+ scores={"keyword": 1.0, "score": 1.0},
+ ),
+ ]
+ second = await step(RuntimeContext(query="alpha", limit=10, tool_context_id="ctx-1"))
- first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- assert [r["id"] for r in first.metadata["results"]] == ["wide"]
-
- # subset (5-10) -> subset of (1,20) -> skipped
- # straddle (15-30) -> not a subset (30>20) -> kept
- # far (40-50) -> not a subset -> kept
- # other (b.md 1-5) -> different path -> kept
- store.keyword_results = [
- FileChunk(
- id="subset",
- path="daily/a.md",
- text="subset",
- start_line=5,
- end_line=10,
- scores={"keyword": 4.0, "score": 4.0},
- ),
- FileChunk(
- id="straddle",
- path="daily/a.md",
- text="straddle",
- start_line=15,
- end_line=30,
- scores={"keyword": 3.0, "score": 3.0},
- ),
- FileChunk(
- id="far",
- path="daily/a.md",
- text="far",
- start_line=40,
- end_line=50,
- scores={"keyword": 2.0, "score": 2.0},
- ),
- FileChunk(
- id="other",
- path="daily/b.md",
- text="other",
- start_line=1,
- end_line=5,
- scores={"keyword": 1.0, "score": 1.0},
- ),
- ]
- second = await step(RuntimeContext(query="alpha", limit=10, tool_context_id="ctx-1"))
-
- ids = [r["id"] for r in second.metadata["results"]]
- assert "subset" not in ids
- assert "straddle" in ids
- assert "far" in ids
- assert "other" in ids
- assert second.metadata["dedup"]["skipped_seen"] == 1
-
- asyncio.run(run())
+ ids = [r["id"] for r in second.metadata["results"]]
+ assert "subset" not in ids
+ assert "straddle" in ids
+ assert "far" in ids
+ assert "other" in ids
+ assert second.metadata["dedup"]["skipped_seen"] == 1
-def test_search_v2_step_tool_context_dedup_merges_adjacent_seen_intervals():
+@pytest.mark.asyncio
+async def test_search_v2_step_tool_context_dedup_merges_adjacent_seen_intervals():
"""Multiple seen entries that jointly cover a new chunk cause it to be skipped.
Adjacent intervals (1,10) and (11,20) merge into (1,20); a new chunk
@@ -463,290 +453,276 @@ def test_search_v2_step_tool_context_dedup_merges_adjacent_seen_intervals():
covered by the merged range.
"""
- async def run():
- app_context = ApplicationContext()
- store = FakeSearchStore(
- keyword_results=[
- FileChunk(
- id="c1",
- path="daily/a.md",
- text="c1",
- start_line=1,
- end_line=10,
- scores={"keyword": 5.0, "score": 5.0},
- ),
- ],
- )
- step = SearchV2Step(
- file_store=store,
- app_context=app_context,
- expand_links=False,
- )
- # Return (1,10) then (11,20) — adjacent, merge into (1,20).
- await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- store.keyword_results = [
+ app_context = ApplicationContext()
+ store = FakeSearchStore(
+ keyword_results=[
FileChunk(
- id="c2",
+ id="c1",
path="daily/a.md",
- text="c2",
- start_line=11,
- end_line=20,
- scores={"keyword": 4.0, "score": 4.0},
+ text="c1",
+ start_line=1,
+ end_line=10,
+ scores={"keyword": 5.0, "score": 5.0},
),
- ]
- await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
+ ],
+ )
+ step = SearchV2Step(
+ file_store=store,
+ app_context=app_context,
+ expand_links=False,
+ )
+ # Return (1,10) then (11,20) — adjacent, merge into (1,20).
+ await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
+ store.keyword_results = [
+ FileChunk(
+ id="c2",
+ path="daily/a.md",
+ text="c2",
+ start_line=11,
+ end_line=20,
+ scores={"keyword": 4.0, "score": 4.0},
+ ),
+ ]
+ await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- # (5,15) -> covered by merged (1,20) -> skipped
- # (5,25) -> NOT covered (25 > 20) -> kept
- # (0,5) -> NOT covered (0 < 1) -> kept
- store.keyword_results = [
- FileChunk(
- id="bridge",
- path="daily/a.md",
- text="bridge",
- start_line=5,
- end_line=15,
- scores={"keyword": 3.0, "score": 3.0},
- ),
- FileChunk(
- id="overshoot",
- path="daily/a.md",
- text="overshoot",
- start_line=5,
- end_line=25,
- scores={"keyword": 2.0, "score": 2.0},
- ),
- FileChunk(
- id="undershoot",
- path="daily/a.md",
- text="undershoot",
- start_line=0,
- end_line=5,
- scores={"keyword": 1.0, "score": 1.0},
- ),
- ]
- resp = await step(RuntimeContext(query="alpha", limit=10, tool_context_id="ctx-1"))
+ # (5,15) -> covered by merged (1,20) -> skipped
+ # (5,25) -> NOT covered (25 > 20) -> kept
+ # (0,5) -> NOT covered (0 < 1) -> kept
+ store.keyword_results = [
+ FileChunk(
+ id="bridge",
+ path="daily/a.md",
+ text="bridge",
+ start_line=5,
+ end_line=15,
+ scores={"keyword": 3.0, "score": 3.0},
+ ),
+ FileChunk(
+ id="overshoot",
+ path="daily/a.md",
+ text="overshoot",
+ start_line=5,
+ end_line=25,
+ scores={"keyword": 2.0, "score": 2.0},
+ ),
+ FileChunk(
+ id="undershoot",
+ path="daily/a.md",
+ text="undershoot",
+ start_line=0,
+ end_line=5,
+ scores={"keyword": 1.0, "score": 1.0},
+ ),
+ ]
+ resp = await step(RuntimeContext(query="alpha", limit=10, tool_context_id="ctx-1"))
- ids = [r["id"] for r in resp.metadata["results"]]
- assert "bridge" not in ids
- assert "overshoot" in ids
- assert "undershoot" in ids
- assert resp.metadata["dedup"]["skipped_seen"] == 1
-
- asyncio.run(run())
+ ids = [r["id"] for r in resp.metadata["results"]]
+ assert "bridge" not in ids
+ assert "overshoot" in ids
+ assert "undershoot" in ids
+ assert resp.metadata["dedup"]["skipped_seen"] == 1
-def test_search_v2_step_empty_query_fails_before_store_calls():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_empty_query_fails_before_store_calls(step_cls):
"""Empty queries fail fast and do not call file_store search methods."""
- async def run():
- store = FakeSearchStore()
- step = SearchV2Step(file_store=store)
- resp = await step(RuntimeContext(query=" ", limit=5))
+ store = FakeSearchStore()
+ step = step_cls(file_store=store)
+ resp = await step(RuntimeContext(query=" ", limit=5))
- assert resp.success is False
- assert resp.answer == "Error: query cannot be empty"
- assert not store.calls
-
- asyncio.run(run())
+ assert resp.success is False
+ assert resp.answer == "Error: query cannot be empty"
+ assert not store.calls
-def test_search_v2_step_start_end_date_promoted_into_search_filter():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_start_end_date_promoted_into_search_filter(step_cls):
"""start_date and end_date from context are promoted into search_filter passed to store."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- end_date="2024-06-30",
- )
+ hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="2024-01-01",
+ end_date="2024-06-30",
+ )
- await step(ctx)
+ await step(ctx)
- assert len(store.calls) == 2
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2024-01-01"
- assert sf["end_date"] == "2024-06-30"
-
- asyncio.run(run())
+ assert len(store.calls) == 2
+ for _, _, _, sf in store.calls:
+ assert sf["start_date"] == "2024-01-01"
+ assert sf["end_date"] == "2024-06-30"
-def test_search_v2_step_invalid_date_is_ignored():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_invalid_date_is_ignored(step_cls):
"""Invalid date strings are silently ignored (removed from filter) and search proceeds."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="abc",
- )
+ hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="abc",
+ )
- resp = await step(ctx)
+ resp = await step(ctx)
- assert resp.success is True
- assert len(store.calls) == 2
- for _, _, _, sf in store.calls:
- assert "start_date" not in sf
-
- asyncio.run(run())
+ assert resp.success is True
+ assert len(store.calls) == 2
+ for _, _, _, sf in store.calls:
+ assert "start_date" not in sf
-def test_search_v2_step_non_normalized_date_is_canonicalized():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_non_normalized_date_is_canonicalized(step_cls):
"""Valid but non-canonical dates like '2024-1-5' are normalized to '2024-01-05'."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-1-5",
- end_date="2024-6-1",
- )
+ hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="2024-1-5",
+ end_date="2024-6-1",
+ )
- await step(ctx)
+ await step(ctx)
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2024-01-05"
- assert sf["end_date"] == "2024-06-01"
-
- asyncio.run(run())
+ for _, _, _, sf in store.calls:
+ assert sf["start_date"] == "2024-01-05"
+ assert sf["end_date"] == "2024-06-01"
-def test_search_v2_step_date_in_search_filter_not_overridden_by_context():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_date_in_search_filter_not_overridden_by_context(step_cls):
"""Explicit search_filter dates take precedence over top-level context dates."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- end_date="2024-06-30",
- search_filter={"start_date": "2023-07-01", "end_date": "2023-12-31"},
- )
+ hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="2024-01-01",
+ end_date="2024-06-30",
+ search_filter={"start_date": "2023-07-01", "end_date": "2023-12-31"},
+ )
- await step(ctx)
+ await step(ctx)
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2023-07-01"
- assert sf["end_date"] == "2023-12-31"
-
- asyncio.run(run())
+ for _, _, _, sf in store.calls:
+ assert sf["start_date"] == "2023-07-01"
+ assert sf["end_date"] == "2023-12-31"
-def test_search_v2_step_strict_date_filter_propagated_to_search_filter():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_strict_date_filter_propagated_to_search_filter(step_cls):
"""strict_date_filter=True is passed through to file_store via search_filter."""
- async def run():
- hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False, strict_date_filter=True)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- end_date="2024-06-30",
- )
+ hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False, strict_date_filter=True)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="2024-01-01",
+ end_date="2024-06-30",
+ )
- await step(ctx)
+ await step(ctx)
- for _, _, _, sf in store.calls:
- assert sf["strict_date_filter"] is True
-
- asyncio.run(run())
+ for _, _, _, sf in store.calls:
+ assert sf["strict_date_filter"] is True
-def test_search_v2_step_non_strict_date_filter_not_in_search_filter():
+@pytest.mark.parametrize("step_cls", _HYBRID_SEARCH_STEPS)
+@pytest.mark.asyncio
+async def test_search_non_strict_date_filter_not_in_search_filter(step_cls):
"""strict_date_filter defaults to False and is not added to search_filter."""
- async def run():
- hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchV2Step(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- )
+ hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
+ store = FakeSearchStore(keyword_results=[hit])
+ step = step_cls(file_store=store, expand_links=False)
+ ctx = RuntimeContext(
+ query="hello",
+ limit=5,
+ start_date="2024-01-01",
+ )
- await step(ctx)
+ await step(ctx)
- for _, _, _, sf in store.calls:
- assert "strict_date_filter" not in sf
-
- asyncio.run(run())
+ for _, _, _, sf in store.calls:
+ assert "strict_date_filter" not in sf
-def test_search_v2_step_all_deduped_shows_all_returned_message():
+@pytest.mark.asyncio
+async def test_search_v2_step_all_deduped_shows_all_returned_message():
"""When tool_context dedup removes every result, the answer explains that all content was previously returned."""
- async def run():
- chunks = [
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- ]
- store = FakeSearchStore(keyword_results=chunks)
- step = SearchV2Step(file_store=store, expand_links=False)
+ chunks = [
+ _chunk("a", "daily/a.md", "first", "keyword", 5.0),
+ _chunk("b", "daily/b.md", "second", "keyword", 4.0),
+ ]
+ store = FakeSearchStore(keyword_results=chunks)
+ step = SearchV2Step(file_store=store, expand_links=False)
- first = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
- second = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
+ first = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
+ second = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
- assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
- assert first.answer != ""
+ assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
+ assert first.answer != ""
- assert second.metadata["results"] == []
- assert second.answer == ALL_RETURNED_MESSAGE
- assert second.metadata["counts"]["returned"] == 0
-
- asyncio.run(run())
+ assert second.metadata["results"] == []
+ assert second.answer == ALL_RETURNED_MESSAGE
+ assert second.metadata["counts"]["returned"] == 0
-def test_plain_search_steps_all_deduped_shows_all_returned_message():
+@pytest.mark.asyncio
+async def test_plain_search_steps_all_deduped_shows_all_returned_message():
"""VectorSearchStep and Bm25SearchStep show the all-returned message when dedup empties results."""
- async def run():
- vector_store = FakeSearchStore(
- vector_results=[
- _chunk("a", "daily/a.md", "first", "vector", 5.0),
- _chunk("b", "daily/b.md", "second", "vector", 4.0),
- ],
- )
- keyword_store = FakeSearchStore(
- keyword_results=[
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- ],
- )
+ vector_store = FakeSearchStore(
+ vector_results=[
+ _chunk("a", "daily/a.md", "first", "vector", 5.0),
+ _chunk("b", "daily/b.md", "second", "vector", 4.0),
+ ],
+ )
+ keyword_store = FakeSearchStore(
+ keyword_results=[
+ _chunk("a", "daily/a.md", "first", "keyword", 5.0),
+ _chunk("b", "daily/b.md", "second", "keyword", 4.0),
+ ],
+ )
- vector = VectorSearchStep(file_store=vector_store, include_source=False)
- bm25 = Bm25SearchStep(file_store=keyword_store, include_source=False)
+ vector = VectorSearchStep(file_store=vector_store, include_source=False)
+ bm25 = Bm25SearchStep(file_store=keyword_store, include_source=False)
- v_first = await vector(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-v"))
- v_second = await vector(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-v"))
+ v_first = await vector(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-v"))
+ v_second = await vector(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-v"))
- b_first = await bm25(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-b"))
- b_second = await bm25(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-b"))
+ b_first = await bm25(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-b"))
+ b_second = await bm25(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-b"))
- assert v_first.answer != ""
- assert v_second.metadata["results"] == []
- assert v_second.answer == ALL_RETURNED_MESSAGE
+ assert v_first.answer != ""
+ assert v_second.metadata["results"] == []
+ assert v_second.answer == ALL_RETURNED_MESSAGE
- assert b_first.answer != ""
- assert b_second.metadata["results"] == []
- assert b_second.answer == ALL_RETURNED_MESSAGE
-
- asyncio.run(run())
+ assert b_first.answer != ""
+ assert b_second.metadata["results"] == []
+ assert b_second.answer == ALL_RETURNED_MESSAGE
def test_search_step_and_range_dedup_states_coexist_in_both_call_orders():
@@ -788,29 +764,27 @@ def test_search_step_and_range_dedup_states_coexist_in_both_call_orders():
asyncio.run(run())
-def test_search_steps_no_results_shows_no_results_message():
+@pytest.mark.asyncio
+async def test_search_steps_no_results_shows_no_results_message():
"""When there are no results at all (before dedup), the answer explains that nothing was found."""
- async def run():
- empty_store = FakeSearchStore()
+ empty_store = FakeSearchStore()
- hybrid = SearchV2Step(file_store=empty_store, expand_links=False)
- vector = VectorSearchStep(file_store=empty_store, include_source=False)
- bm25 = Bm25SearchStep(file_store=empty_store, include_source=False)
+ hybrid = SearchV2Step(file_store=empty_store, expand_links=False)
+ vector = VectorSearchStep(file_store=empty_store, include_source=False)
+ bm25 = Bm25SearchStep(file_store=empty_store, include_source=False)
- # With tool_context_id set (dedup path, but nothing to dedup)
- for step in (hybrid, vector, bm25):
- resp = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
- assert resp.metadata["results"] == []
- assert resp.answer == NO_RESULTS_MESSAGE
+ # With tool_context_id set (dedup path, but nothing to dedup)
+ for step in (hybrid, vector, bm25):
+ resp = await step(RuntimeContext(query="alpha", limit=5, tool_context_id="ctx-1"))
+ assert resp.metadata["results"] == []
+ assert resp.answer == NO_RESULTS_MESSAGE
- # Without tool_context_id (plain truncation path)
- for step in (hybrid, vector, bm25):
- resp = await step(RuntimeContext(query="alpha", limit=5))
- assert resp.metadata["results"] == []
- assert resp.answer == NO_RESULTS_MESSAGE
-
- asyncio.run(run())
+ # Without tool_context_id (plain truncation path)
+ for step in (hybrid, vector, bm25):
+ resp = await step(RuntimeContext(query="alpha", limit=5))
+ assert resp.metadata["results"] == []
+ assert resp.answer == NO_RESULTS_MESSAGE
_QUERY_STEP_CLASSES = [
@@ -825,669 +799,354 @@ _QUERY_STEP_CLASSES = [
@pytest.mark.parametrize("value", ["404", "0", "0.0"])
@pytest.mark.parametrize("step_cls", _QUERY_STEP_CLASSES)
-def test_search_steps_search_for_the_text_of_a_numeric_query(step_cls, value):
+@pytest.mark.asyncio
+async def test_search_steps_search_for_the_text_of_a_numeric_query(step_cls, value):
"""The CLI parses ``query=0`` as a number; the search steps still search for its text."""
- async def run():
- store = FakeSearchStore(
- vector_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "vector", 0.9)],
- keyword_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "keyword", 4.0)],
- )
- query = parse_kwargs(f"query={value}")["query"]
- resp = await step_cls(file_store=store)(RuntimeContext(query=query, limit=5))
+ store = FakeSearchStore(
+ vector_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "vector", 0.9)],
+ keyword_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "keyword", 4.0)],
+ )
+ query = parse_kwargs(f"query={value}")["query"]
+ resp = await step_cls(file_store=store)(RuntimeContext(query=query, limit=5))
- assert resp.success is True
- assert store.calls and {call[1] for call in store.calls} == {value}
- assert "digest/a.md" in resp.answer
-
- asyncio.run(run())
+ assert resp.success is True
+ assert store.calls and {call[1] for call in store.calls} == {value}
+ assert "digest/a.md" in resp.answer
# ---------------------------------------------------------------------------
-# SearchStep tests — exercise the SearchStep (simple chunk.id dedup,
-# inline answer formatting).
+# SearchStep-specific tag filtering (shared search contracts are parameterized above).
# ---------------------------------------------------------------------------
-def test_search_step_rrf_merges_vector_and_keyword_by_chunk_id():
- """Hybrid search fuses same-id hits once and keeps per-branch scores in metadata."""
-
- async def run():
- shared_v = _chunk("shared", "daily/a.md", "shared vector text", "vector", 0.92, line=3)
- vector_only = _chunk("vector-only", "daily/b.md", "vector text", "vector", 0.71)
- keyword_only = _chunk("keyword-only", "digest/c.md", "keyword text", "keyword", 8.0)
- shared_k = _chunk("shared", "daily/a.md", "shared keyword text", "keyword", 7.0, line=3)
- store = FakeSearchStore(
- vector_results=[shared_v, vector_only],
- keyword_results=[keyword_only, shared_k],
- )
- step = SearchStep(file_store=store, vector_weight=0.5, candidate_multiplier=2, expand_links=False)
- ctx = RuntimeContext(query="alpha", limit=3, search_filter={"path_prefix": "daily/"})
-
- resp = await step(ctx)
-
- assert resp.success is True
- assert resp.metadata["counts"] == {"vector": 2, "keyword": 2, "returned": 3, "hybrid": True}
- assert [r["id"] for r in resp.metadata["results"]] == ["shared", "keyword-only", "vector-only"]
- shared = resp.metadata["results"][0]
- assert shared["scores"]["vector"] == 0.92
- assert shared["scores"]["keyword"] == 7.0
- assert shared["scores"]["score"] > resp.metadata["results"][1]["scores"]["score"]
- assert "daily/a.md:3-3" in resp.answer
- assert "vector=0.9200" in resp.answer
- assert "keyword=7.0000" in resp.answer
- assert {call[0] for call in store.calls} == {"vector", "keyword"}
- assert all(call[2] == 6 for call in store.calls)
- assert all(call[3] == {"path_prefix": "daily/"} for call in store.calls)
-
- asyncio.run(run())
-
-
-def test_search_step_keyword_only_uses_keyword_scores_and_min_score():
- """When vector has no hits, SearchStep returns keyword results directly and applies min_score."""
-
- async def run():
- high = _chunk("high", "daily/high.md", "strong keyword hit", "keyword", 4.0)
- low = _chunk("low", "daily/low.md", "weak keyword hit", "keyword", 0.2)
- store = FakeSearchStore(keyword_results=[high, low])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(query="keyword", limit=5, min_score=1.0)
-
- resp = await step(ctx)
-
- assert resp.metadata["counts"] == {"vector": 0, "keyword": 2, "returned": 1, "hybrid": False}
- assert [r["id"] for r in resp.metadata["results"]] == ["high"]
- assert "keyword=4.0000" not in resp.answer
- assert "score=4.0000" in resp.answer
- assert "daily/low.md" not in resp.answer
-
- asyncio.run(run())
-
-
-def test_search_step_tool_context_deduplicates_returned_chunks_only():
- """When tool_context_id is supplied, repeated searches skip previously returned chunks."""
-
- async def run():
- chunks = [
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- _chunk("c", "daily/c.md", "third", "keyword", 3.0),
- ]
- store = FakeSearchStore(keyword_results=chunks)
- step = SearchStep(file_store=store, expand_links=False)
-
- first = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
- second = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
- third = await step(RuntimeContext(query="alpha", limit=2))
-
- assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
- assert first.metadata["dedup"] == {
- "tool_context_id": "ctx-1",
- "seen_before": 0,
- "skipped_seen": 0,
- "seen_after": 2,
- "expired": 0,
- "ttl_seconds": 86400.0,
- }
- assert [r["id"] for r in second.metadata["results"]] == ["c"]
- assert second.metadata["dedup"]["seen_before"] == 2
- assert second.metadata["dedup"]["skipped_seen"] == 2
- assert second.metadata["dedup"]["seen_after"] == 3
- assert [r["id"] for r in third.metadata["results"]] == ["a", "b"]
- assert "dedup" not in third.metadata
-
- asyncio.run(run())
-
-
-def test_search_step_passes_metadata_filter_to_store():
- """Search filters can target chunk metadata such as conversation_date."""
-
- async def run():
- hit = _chunk("hit", "daily/2023-01-19/event.md", "historical hit", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- search_filter = {"metadata": {"conversation_date": "2023-01-19"}}
-
- resp = await step(RuntimeContext(query="Jon job", limit=5, search_filter=search_filter))
-
- assert resp.success is True
- assert resp.metadata["results"][0]["id"] == "hit"
- assert all(call[3] == search_filter for call in store.calls)
-
- asyncio.run(run())
-
-
-def test_search_step_tool_context_seen_chunks_expire_after_ttl():
- """Seen chunk ids under a tool_context_id are reusable after the configured TTL."""
-
- async def run():
- now = 1000.0
- chunks = [
- _chunk("a", "daily/a.md", "first", "keyword", 5.0),
- _chunk("b", "daily/b.md", "second", "keyword", 4.0),
- ]
- store = FakeSearchStore(keyword_results=chunks)
- step = SearchStep(
- file_store=store,
- expand_links=False,
- seen_ttl_hours=1,
- clock=lambda: now,
- )
-
- first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
- now = 4601.0
- second = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
-
- assert [r["id"] for r in first.metadata["results"]] == ["a"]
- assert [r["id"] for r in second.metadata["results"]] == ["a"]
- assert second.metadata["dedup"]["expired"] == 1
- assert second.metadata["dedup"]["seen_before"] == 0
- assert second.metadata["dedup"]["ttl_seconds"] == 3600.0
-
- asyncio.run(run())
-
-
-def test_search_step_empty_query_fails_before_store_calls():
- """Empty queries fail fast and do not call file_store search methods."""
-
- async def run():
- store = FakeSearchStore()
- step = SearchStep(file_store=store)
- resp = await step(RuntimeContext(query=" ", limit=5))
-
- assert resp.success is False
- assert resp.answer == "Error: query cannot be empty"
- assert not store.calls
-
- asyncio.run(run())
-
-
-def test_search_step_falls_back_for_unavailable_index_and_rejects_invalid_tags():
+@pytest.mark.asyncio
+async def test_search_step_falls_back_for_unavailable_index_and_rejects_invalid_tags():
"""Unavailable optional indexes preserve search, while malformed tags still fail."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
- missing = FakeSearchStore(keyword_results=[hit])
- unhealthy = TaggedFakeSearchStore(chunks=[hit])
- unhealthy.tag_index.set_healthy(False)
+ hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
+ missing = FakeSearchStore(keyword_results=[hit])
+ unhealthy = TaggedFakeSearchStore(chunks=[hit])
+ unhealthy.tag_index.set_healthy(False)
- for store in (missing, unhealthy):
- resp = await SearchStep(file_store=store, expand_links=False)(
- RuntimeContext(query="hello", limit=5, tags=["python"]),
- )
- assert resp.success is True
- assert [result["id"] for result in resp.metadata["results"]] == ["hit"]
- assert {call[0] for call in store.calls} == {"vector", "keyword"}
- assert all(call[3] == {} for call in store.calls)
- assert resp.metadata["tag_filter"] == {
- "requested": True,
- "applied": False,
- "reason": "tag_index_unavailable",
- }
-
- invalid = TaggedFakeSearchStore(chunks=[])
- resp = await SearchStep(file_store=invalid, expand_links=False)(
- RuntimeContext(query="hello", limit=5, tags=["!", "++", "x" * 65]),
+ for store in (missing, unhealthy):
+ resp = await SearchStep(file_store=store, expand_links=False)(
+ RuntimeContext(query="hello", limit=5, tags=["python"]),
)
- assert resp.success is False
- assert resp.answer == "Error: tags contained no valid values"
- assert not invalid.calls
-
- asyncio.run(run())
-
-
-def test_search_step_combines_tags_with_existing_chunk_filters():
- """Tags use OR internally and are ANDed with existing path/date behavior."""
-
- async def run():
- matching = _chunk("a", "daily/2024-03-01/a.md", "match", "keyword", 3.0)
- old = _chunk("b", "daily/2023-03-01/b.md", "old", "keyword", 2.0)
- wrong_prefix = _chunk("c", "resource/2024-03-01/c.md", "resource", "keyword", 1.0)
- store = TaggedFakeSearchStore(chunks=[matching, old, wrong_prefix])
- await store.tag_index.rebuild(
- [
- FileNode(path=matching.path, st_mtime=1.0, front_matter={"memory_tags": ["Python"]}),
- FileNode(path=old.path, st_mtime=1.0, front_matter={"memory_tags": ["ReMe"]}),
- FileNode(path=wrong_prefix.path, st_mtime=1.0, front_matter={"memory_tags": ["python"]}),
- ],
- )
- step = SearchStep(file_store=store, expand_links=False)
-
- resp = await step(
- RuntimeContext(
- query="hello",
- limit=5,
- tags=[" PYTHON ", "REME"],
- start_date="2024-01-01",
- search_filter={"path_prefix": "daily/"},
- ),
- )
-
assert resp.success is True
- assert [result["id"] for result in resp.metadata["results"]] == ["a"]
+ assert [result["id"] for result in resp.metadata["results"]] == ["hit"]
+ assert {call[0] for call in store.calls} == {"vector", "keyword"}
+ assert all(call[3] == {} for call in store.calls)
assert resp.metadata["tag_filter"] == {
"requested": True,
- "applied": True,
- "tags": ["python", "reme"],
- "matched_paths": 3,
+ "applied": False,
+ "reason": "tag_index_unavailable",
}
- assert {call[0] for call in store.calls} == {"vector", "keyword"}
- assert all(set(call[3]["paths"]) == {matching.path, old.path, wrong_prefix.path} for call in store.calls)
- store.calls.clear()
- resp = await step(RuntimeContext(query="hello", limit=5, tags=["missing"]))
- assert resp.success is True
- assert resp.metadata["results"] == []
- assert resp.metadata["tag_filter"]["matched_paths"] == 0
- assert all(call[3]["paths"] == [] for call in store.calls)
- asyncio.run(run())
+ invalid = TaggedFakeSearchStore(chunks=[])
+ resp = await SearchStep(file_store=invalid, expand_links=False)(
+ RuntimeContext(query="hello", limit=5, tags=["!", "++", "x" * 65]),
+ )
+ assert resp.success is False
+ assert resp.answer == "Error: tags contained no valid values"
+ assert not invalid.calls
-def test_search_step_start_end_date_promoted_into_search_filter():
- """start_date and end_date from context are promoted into search_filter passed to store."""
+@pytest.mark.asyncio
+async def test_search_step_combines_tags_with_existing_chunk_filters():
+ """Tags use OR internally and are ANDed with existing path/date behavior."""
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(
+ matching = _chunk("a", "daily/2024-03-01/a.md", "match", "keyword", 3.0)
+ old = _chunk("b", "daily/2023-03-01/b.md", "old", "keyword", 2.0)
+ wrong_prefix = _chunk("c", "resource/2024-03-01/c.md", "resource", "keyword", 1.0)
+ store = TaggedFakeSearchStore(chunks=[matching, old, wrong_prefix])
+ await store.tag_index.rebuild(
+ [
+ FileNode(path=matching.path, st_mtime=1.0, front_matter={"memory_tags": ["Python"]}),
+ FileNode(path=old.path, st_mtime=1.0, front_matter={"memory_tags": ["ReMe"]}),
+ FileNode(path=wrong_prefix.path, st_mtime=1.0, front_matter={"memory_tags": ["python"]}),
+ ],
+ )
+ step = SearchStep(file_store=store, expand_links=False)
+
+ resp = await step(
+ RuntimeContext(
query="hello",
limit=5,
+ tags=[" PYTHON ", "REME"],
start_date="2024-01-01",
- end_date="2024-06-30",
- )
+ search_filter={"path_prefix": "daily/"},
+ ),
+ )
- await step(ctx)
-
- assert len(store.calls) == 2
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2024-01-01"
- assert sf["end_date"] == "2024-06-30"
-
- asyncio.run(run())
+ assert resp.success is True
+ assert [result["id"] for result in resp.metadata["results"]] == ["a"]
+ assert resp.metadata["tag_filter"] == {
+ "requested": True,
+ "applied": True,
+ "tags": ["python", "reme"],
+ "matched_paths": 3,
+ }
+ assert {call[0] for call in store.calls} == {"vector", "keyword"}
+ assert all(set(call[3]["paths"]) == {matching.path, old.path, wrong_prefix.path} for call in store.calls)
+ store.calls.clear()
+ resp = await step(RuntimeContext(query="hello", limit=5, tags=["missing"]))
+ assert resp.success is True
+ assert resp.metadata["results"] == []
+ assert resp.metadata["tag_filter"]["matched_paths"] == 0
+ assert all(call[3]["paths"] == [] for call in store.calls)
-def test_search_step_invalid_date_is_ignored():
- """Invalid date strings are silently ignored (removed from filter) and search proceeds."""
-
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="abc",
- )
-
- resp = await step(ctx)
-
- assert resp.success is True
- assert len(store.calls) == 2
- for _, _, _, sf in store.calls:
- assert "start_date" not in sf
-
- asyncio.run(run())
-
-
-def test_search_step_non_normalized_date_is_canonicalized():
- """Valid but non-canonical dates like '2024-1-5' are normalized to '2024-01-05'."""
-
- async def run():
- hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-1-5",
- end_date="2024-6-1",
- )
-
- await step(ctx)
-
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2024-01-05"
- assert sf["end_date"] == "2024-06-01"
-
- asyncio.run(run())
-
-
-def test_search_step_date_in_search_filter_not_overridden_by_context():
- """Explicit search_filter dates take precedence over top-level context dates."""
-
- async def run():
- hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- end_date="2024-06-30",
- search_filter={"start_date": "2023-07-01", "end_date": "2023-12-31"},
- )
-
- await step(ctx)
-
- for _, _, _, sf in store.calls:
- assert sf["start_date"] == "2023-07-01"
- assert sf["end_date"] == "2023-12-31"
-
- asyncio.run(run())
-
-
-def test_search_step_strict_date_filter_propagated_to_search_filter():
- """strict_date_filter=True is passed through to file_store via search_filter."""
-
- async def run():
- hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False, strict_date_filter=True)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- end_date="2024-06-30",
- )
-
- await step(ctx)
-
- for _, _, _, sf in store.calls:
- assert sf["strict_date_filter"] is True
-
- asyncio.run(run())
-
-
-def test_search_step_non_strict_date_filter_not_in_search_filter():
- """strict_date_filter defaults to False and is not added to search_filter."""
-
- async def run():
- hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
- store = FakeSearchStore(keyword_results=[hit])
- step = SearchStep(file_store=store, expand_links=False)
- ctx = RuntimeContext(
- query="hello",
- limit=5,
- start_date="2024-01-01",
- )
-
- await step(ctx)
-
- for _, _, _, sf in store.calls:
- assert "strict_date_filter" not in sf
-
- asyncio.run(run())
-
-
-def test_search_v2_step_compresses_session_bodies_when_injected_flag_set():
+@pytest.mark.asyncio
+async def test_search_v2_step_compresses_session_bodies_when_injected_flag_set():
"""With ``_search._compress.session`` truthy, session bodies are replaced by the
compressor job output while headers and non-session entries stay untouched."""
- async def run():
- session = _chunk("s1", "session/dialog/s1.jsonl", "hello world dialog text", "vector", 0.9, line=2)
- note = _chunk("n1", "daily/a.md", "note text", "vector", 0.8)
- store = FakeSearchStore(vector_results=[session, note])
- step = SearchV2Step(file_store=store, expand_links=False)
- calls: list[dict] = []
+ session = _chunk("s1", "session/dialog/s1.jsonl", "hello world dialog text", "vector", 0.9, line=2)
+ note = _chunk("n1", "daily/a.md", "note text", "vector", 0.8)
+ store = FakeSearchStore(vector_results=[session, note])
+ step = SearchV2Step(file_store=store, expand_links=False)
+ calls: list[dict] = []
- async def fake_run_job(name, /, **kwargs):
- calls.append({"name": name, **kwargs})
- return Response(success=True, answer="compressed!")
+ async def fake_run_job(name, /, **kwargs):
+ calls.append({"name": name, **kwargs})
+ return Response(success=True, answer="compressed!")
- step.run_job = fake_run_job
- ctx = RuntimeContext(
- query="alpha",
- limit=5,
- _search={"_compress": {"session": "true"}, "queries": ["orig question"]},
- )
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(
+ query="alpha",
+ limit=5,
+ _search={"_compress": {"session": "true"}, "queries": ["orig question"]},
+ )
- resp = await step(ctx)
+ resp = await step(ctx)
- assert resp.success is True
- assert "compressed!" in resp.answer
- assert "hello world dialog text" not in resp.answer
- assert "note text" in resp.answer
- assert "session/dialog/s1.jsonl:2-2" in resp.answer
- assert calls == [
- {"name": "compressor", "text": "hello world dialog text", "queries": ["orig question", "alpha"]},
- ]
-
- asyncio.run(run())
+ assert resp.success is True
+ assert "compressed!" in resp.answer
+ assert "hello world dialog text" not in resp.answer
+ assert "note text" in resp.answer
+ assert "session/dialog/s1.jsonl:2-2" in resp.answer
+ assert calls == [
+ {"name": "compressor", "text": "hello world dialog text", "queries": ["orig question", "alpha"]},
+ ]
-def test_search_v2_step_query_independent_compression_passes_no_queries():
+@pytest.mark.asyncio
+async def test_search_v2_step_query_independent_compression_passes_no_queries():
"""With ``_search.type`` = ``query-independent`` the compressor receives no queries;
any other (or missing) type keeps the query-aware behavior."""
- async def run():
- session = _chunk("s1", "session/dialog/s1.jsonl", "hello world dialog text", "vector", 0.9)
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
- calls: list[dict] = []
+ session = _chunk("s1", "session/dialog/s1.jsonl", "hello world dialog text", "vector", 0.9)
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
+ calls: list[dict] = []
- async def fake_run_job(name, /, **kwargs):
- calls.append({"name": name, **kwargs})
- return Response(success=True, answer="compressed!")
+ async def fake_run_job(name, /, **kwargs):
+ calls.append({"name": name, **kwargs})
+ return Response(success=True, answer="compressed!")
- step.run_job = fake_run_job
- ctx = RuntimeContext(
- query="alpha",
- limit=5,
- _search={"_compress": {"session": "true"}, "queries": ["orig question"], "type": "query-independent"},
- )
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(
+ query="alpha",
+ limit=5,
+ _search={"_compress": {"session": "true"}, "queries": ["orig question"], "type": "query-independent"},
+ )
- resp = await step(ctx)
+ resp = await step(ctx)
- assert "compressed!" in resp.answer
- assert calls == [{"name": "compressor", "text": "hello world dialog text", "queries": []}]
+ assert "compressed!" in resp.answer
+ assert calls == [{"name": "compressor", "text": "hello world dialog text", "queries": []}]
- # Explicit query-aware type behaves exactly like the default.
- calls.clear()
- ctx = RuntimeContext(
- query="alpha",
- limit=5,
- _search={"_compress": {"session": "true"}, "queries": ["orig question"], "type": "query-aware"},
- )
+ # Explicit query-aware type behaves exactly like the default.
+ calls.clear()
+ ctx = RuntimeContext(
+ query="alpha",
+ limit=5,
+ _search={"_compress": {"session": "true"}, "queries": ["orig question"], "type": "query-aware"},
+ )
- await step(ctx)
+ await step(ctx)
- assert calls == [
- {"name": "compressor", "text": "hello world dialog text", "queries": ["orig question", "alpha"]},
- ]
-
- asyncio.run(run())
+ assert calls == [
+ {"name": "compressor", "text": "hello world dialog text", "queries": ["orig question", "alpha"]},
+ ]
-def test_search_v2_step_keeps_original_body_when_compressed_is_longer():
+@pytest.mark.asyncio
+async def test_search_v2_step_keeps_original_body_when_compressed_is_longer():
"""A compressed body longer than the original is rejected in favor of the original."""
- async def run():
- session = _chunk("s1", "session/dialog/s1.jsonl", "short", "vector", 0.9)
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
+ session = _chunk("s1", "session/dialog/s1.jsonl", "short", "vector", 0.9)
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
- async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
- return Response(success=True, answer="a much longer compressed output")
+ async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
+ return Response(success=True, answer="a much longer compressed output")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": True}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": True}})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert "short" in resp.answer
- assert "much longer" not in resp.answer
-
- asyncio.run(run())
+ assert "short" in resp.answer
+ assert "much longer" not in resp.answer
-def test_search_v2_step_compression_strips_input_and_adds_marker():
+@pytest.mark.asyncio
+async def test_search_v2_step_compression_strips_input_and_adds_marker():
"""The compressor receives the stripped rendered body; adopted output is
prefixed with the ``compressed session chunk:`` marker only (no line numbers)."""
- async def run():
- session = FileChunk(
- id="s1",
- path="session/dialog/s1.jsonl",
- text="first message line\nsecond message line",
- start_line=4,
- end_line=5,
- scores={"vector": 0.9, "score": 0.9},
- )
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
- calls: list[dict] = []
+ session = FileChunk(
+ id="s1",
+ path="session/dialog/s1.jsonl",
+ text="first message line\nsecond message line",
+ start_line=4,
+ end_line=5,
+ scores={"vector": 0.9, "score": 0.9},
+ )
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
+ calls: list[dict] = []
- async def fake_run_job(name, /, **kwargs):
- calls.append({"name": name, **kwargs})
- return Response(success=True, answer="first compact\nsecond compact\n")
+ async def fake_run_job(name, /, **kwargs):
+ calls.append({"name": name, **kwargs})
+ return Response(success=True, answer="first compact\nsecond compact\n")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert calls[0]["text"] == "first message line\nsecond message line"
- assert "compressed session chunk:\nfirst compact\nsecond compact" in resp.answer
-
- asyncio.run(run())
+ assert calls[0]["text"] == "first message line\nsecond message line"
+ assert "compressed session chunk:\nfirst compact\nsecond compact" in resp.answer
-def test_search_v2_step_compression_input_is_line_aligned_with_session_jsonl():
+@pytest.mark.asyncio
+async def test_search_v2_step_compression_input_is_line_aligned_with_session_jsonl():
"""Serialized Msg jsonl lines render one message per line (newlines flattened),
so compressor input line ``i`` maps to session file line ``start_line + i``."""
- async def run():
- m1 = Msg(name="user", role="user", content=[{"type": "text", "text": "line one\nline two"}])
- m2 = Msg(name="assistant", role="assistant", content=[{"type": "text", "text": "long answer"}])
- session = FileChunk(
- id="s1",
- path="session/dialog/s1.jsonl",
- text=m1.model_dump_json() + "\n" + m2.model_dump_json(),
- start_line=7,
- end_line=8,
- scores={"vector": 0.9, "score": 0.9},
- )
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
- calls: list[dict] = []
+ m1 = Msg(name="user", role="user", content=[{"type": "text", "text": "line one\nline two"}])
+ m2 = Msg(name="assistant", role="assistant", content=[{"type": "text", "text": "long answer"}])
+ session = FileChunk(
+ id="s1",
+ path="session/dialog/s1.jsonl",
+ text=m1.model_dump_json() + "\n" + m2.model_dump_json(),
+ start_line=7,
+ end_line=8,
+ scores={"vector": 0.9, "score": 0.9},
+ )
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
+ calls: list[dict] = []
- async def fake_run_job(name, /, **kwargs):
- calls.append({"name": name, **kwargs})
- return Response(success=True, answer="[user @ t] u\n[assistant @ t] a")
+ async def fake_run_job(name, /, **kwargs):
+ calls.append({"name": name, **kwargs})
+ return Response(success=True, answer="[user @ t] u\n[assistant @ t] a")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
- resp = await step(ctx)
+ resp = await step(ctx)
- sent_lines = calls[0]["text"].splitlines()
- assert len(sent_lines) == 2
- assert sent_lines[0].startswith("[user @") and "line one line two" in sent_lines[0]
- assert sent_lines[1].startswith("[assistant @") and "long answer" in sent_lines[1]
- assert "compressed session chunk:\n[user @ t] u\n[assistant @ t] a" in resp.answer
-
- asyncio.run(run())
+ sent_lines = calls[0]["text"].splitlines()
+ assert len(sent_lines) == 2
+ assert sent_lines[0].startswith("[user @") and "line one line two" in sent_lines[0]
+ assert sent_lines[1].startswith("[assistant @") and "long answer" in sent_lines[1]
+ assert "compressed session chunk:\n[user @ t] u\n[assistant @ t] a" in resp.answer
-def test_search_v2_step_adopts_shorter_output_regardless_of_line_count():
+@pytest.mark.asyncio
+async def test_search_v2_step_adopts_shorter_output_regardless_of_line_count():
"""A shorter compressor output is adopted even when its line count differs."""
- async def run():
- session = FileChunk(
- id="s1",
- path="session/dialog/s1.jsonl",
- text="first message line\nsecond message line",
- start_line=4,
- end_line=5,
- scores={"vector": 0.9, "score": 0.9},
- )
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
+ session = FileChunk(
+ id="s1",
+ path="session/dialog/s1.jsonl",
+ text="first message line\nsecond message line",
+ start_line=4,
+ end_line=5,
+ scores={"vector": 0.9, "score": 0.9},
+ )
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
- async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
- return Response(success=True, answer="merged into one line")
+ async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
+ return Response(success=True, answer="merged into one line")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert "compressed session chunk:\nmerged into one line" in resp.answer
- assert "first message line" not in resp.answer
-
- asyncio.run(run())
+ assert "compressed session chunk:\nmerged into one line" in resp.answer
+ assert "first message line" not in resp.answer
-def test_search_v2_step_skips_compression_without_injected_flag():
+@pytest.mark.asyncio
+async def test_search_v2_step_skips_compression_without_injected_flag():
"""Without the injected ``_search`` payload the compressor job is never invoked."""
- async def run():
- session = _chunk("s1", "session/dialog/s1.jsonl", "dialog text", "vector", 0.9)
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
+ session = _chunk("s1", "session/dialog/s1.jsonl", "dialog text", "vector", 0.9)
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
- async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
- raise AssertionError("compressor job must not be called")
+ async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
+ raise AssertionError("compressor job must not be called")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha")
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha")
- resp = await step(ctx)
+ resp = await step(ctx)
- assert "dialog text" in resp.answer
-
- asyncio.run(run())
+ assert "dialog text" in resp.answer
-def test_search_v2_step_keeps_original_body_when_compression_fails():
+@pytest.mark.asyncio
+async def test_search_v2_step_keeps_original_body_when_compression_fails():
"""A failed compressor response (success=False) never replaces the body."""
- async def run():
- session = _chunk("s1", "session/dialog/s1.jsonl", "dialog text", "vector", 0.9)
- store = FakeSearchStore(vector_results=[session])
- step = SearchV2Step(file_store=store, expand_links=False)
+ session = _chunk("s1", "session/dialog/s1.jsonl", "dialog text", "vector", 0.9)
+ store = FakeSearchStore(vector_results=[session])
+ step = SearchV2Step(file_store=store, expand_links=False)
- async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
- return Response(success=False, answer="boom")
+ async def fake_run_job(name, /, **kwargs): # pylint: disable=unused-argument
+ return Response(success=False, answer="boom")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert "dialog text" in resp.answer
- assert "boom" not in resp.answer
-
- asyncio.run(run())
+ assert "dialog text" in resp.answer
+ assert "boom" not in resp.answer
-def test_search_v2_step_keeps_original_body_when_compression_raises():
+@pytest.mark.asyncio
+async def test_search_v2_step_keeps_original_body_when_compression_raises():
"""A compressor job that raises (e.g. temporary LLM outage) keeps the original
body for that entry while other entries are still compressed; the search itself
never fails."""
- async def run():
- failing = _chunk("s1", "session/dialog/s1.jsonl", "dialog text one", "vector", 0.9, line=2)
- ok = _chunk("s2", "session/dialog/s2.jsonl", "dialog text two", "vector", 0.8, line=1)
- store = FakeSearchStore(vector_results=[failing, ok])
- step = SearchV2Step(file_store=store, expand_links=False)
+ failing = _chunk("s1", "session/dialog/s1.jsonl", "dialog text one", "vector", 0.9, line=2)
+ ok = _chunk("s2", "session/dialog/s2.jsonl", "dialog text two", "vector", 0.8, line=1)
+ store = FakeSearchStore(vector_results=[failing, ok])
+ step = SearchV2Step(file_store=store, expand_links=False)
- async def fake_run_job(_name, /, **kwargs):
- if "one" in kwargs.get("text", ""):
- raise RuntimeError("temporary LLM outage")
- return Response(success=True, answer="compressed!")
+ async def fake_run_job(_name, /, **kwargs):
+ if "one" in kwargs.get("text", ""):
+ raise RuntimeError("temporary LLM outage")
+ return Response(success=True, answer="compressed!")
- step.run_job = fake_run_job
- ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
+ step.run_job = fake_run_job
+ ctx = RuntimeContext(query="alpha", _search={"_compress": {"session": "true"}})
- resp = await step(ctx)
+ resp = await step(ctx)
- assert resp.success is True
- # The failing entry keeps its original body.
- assert "dialog text one" in resp.answer
- # The healthy entry is still compressed.
- assert "compressed!" in resp.answer
- assert "dialog text two" not in resp.answer
-
- asyncio.run(run())
+ assert resp.success is True
+ # The failing entry keeps its original body.
+ assert "dialog text one" in resp.answer
+ # The healthy entry is still compressed.
+ assert "compressed!" in resp.answer
+ assert "dialog text two" not in resp.answer
diff --git a/tests/unit/test_shell_step.py b/tests/unit/test_shell_step.py
index db04c393..4d2ac74e 100644
--- a/tests/unit/test_shell_step.py
+++ b/tests/unit/test_shell_step.py
@@ -1,19 +1,15 @@
"""Tests for asynchronous shell command execution."""
-import asyncio
import time
+import pytest
+
from reme.components.application_context import ApplicationContext
from reme.enumeration import ComponentEnum
from reme.steps.common.shell import DEFAULT_TIMEOUT, ShellStep
from reme.components import R
-def _run(coro):
- """Run a coroutine on a fresh event loop."""
- asyncio.run(coro)
-
-
def test_shell_step_is_registered():
"""Importing common steps makes shell_step discoverable."""
assert R.get(ComponentEnum.STEP, "shell_step") is ShellStep
@@ -24,77 +20,67 @@ def test_shell_step_default_timeout_is_one_day():
assert DEFAULT_TIMEOUT == 86400
-def test_shell_step_returns_stdout_from_workspace(tmp_path):
+@pytest.mark.asyncio
+async def test_shell_step_returns_stdout_from_workspace(tmp_path):
"""Successful commands return stdout and run in the configured workspace."""
- async def run():
- step = ShellStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
- response = await step(cmd="pwd")
+ step = ShellStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
+ response = await step(cmd="pwd")
- assert response.success is True
- assert response.answer.strip() == str(tmp_path)
- assert response.metadata["returncode"] == 0
- assert response.metadata["stderr"] == ""
-
- _run(run())
+ assert response.success is True
+ assert response.answer.strip() == str(tmp_path)
+ assert response.metadata["returncode"] == 0
+ assert response.metadata["stderr"] == ""
-def test_shell_step_reports_stderr_on_failure():
+@pytest.mark.asyncio
+async def test_shell_step_reports_stderr_on_failure():
"""Failed commands expose stderr and their non-zero exit status."""
- async def run():
- step = ShellStep()
- response = await step(cmd="echo boom >&2; exit 7")
+ step = ShellStep()
+ response = await step(cmd="echo boom >&2; exit 7")
- assert response.success is False
- assert response.answer == "boom\n"
- assert response.metadata["returncode"] == 7
- assert response.metadata["stderr"] == "boom\n"
-
- _run(run())
+ assert response.success is False
+ assert response.answer == "boom\n"
+ assert response.metadata["returncode"] == 7
+ assert response.metadata["stderr"] == "boom\n"
-def test_shell_step_times_out():
+@pytest.mark.asyncio
+async def test_shell_step_times_out():
"""Timeout terminates child processes as well as their parent shell."""
- async def run():
- step = ShellStep()
- started = time.monotonic()
- response = await step(cmd="sleep 3 & wait", shell_timeout=0.01)
+ step = ShellStep()
+ started = time.monotonic()
+ response = await step(cmd="sleep 3 & wait", shell_timeout=0.01)
- assert response.success is False
- assert response.answer == "Shell command timed out after 0.01s"
- assert response.metadata["shell_timeout"] == 0.01
- assert time.monotonic() - started < 1
-
- _run(run())
+ assert response.success is False
+ assert response.answer == "Shell command timed out after 0.01s"
+ assert response.metadata["shell_timeout"] == 0.01
+ assert time.monotonic() - started < 1
-def test_shell_step_requires_a_command():
+@pytest.mark.asyncio
+async def test_shell_step_requires_a_command():
"""Blank commands are rejected without creating a subprocess."""
- async def run():
- response = await ShellStep()(cmd=" ")
+ response = await ShellStep()(cmd=" ")
- assert response.success is False
- assert response.answer == "cmd is required"
-
- _run(run())
+ assert response.success is False
+ assert response.answer == "cmd is required"
-def test_shell_step_does_not_accept_legacy_parameter_names():
+@pytest.mark.asyncio
+async def test_shell_step_does_not_accept_legacy_parameter_names():
"""Only cmd and shell_timeout configure shell execution."""
- async def run():
- response = await ShellStep()(command="printf legacy", timeout=1)
+ response = await ShellStep()(command="printf legacy", timeout=1)
- assert response.success is False
- assert response.answer == "cmd is required"
+ assert response.success is False
+ assert response.answer == "cmd is required"
- response = await ShellStep()(cmd="printf current", timeout=1)
+ response = await ShellStep()(cmd="printf current", timeout=1)
- assert response.success is True
- assert response.answer == "current"
- assert response.metadata["shell_timeout"] == DEFAULT_TIMEOUT
-
- _run(run())
+ assert response.success is True
+ assert response.answer == "current"
+ assert response.metadata["shell_timeout"] == DEFAULT_TIMEOUT
diff --git a/tests/unit/test_tag_index.py b/tests/unit/test_tag_index.py
index 845eb279..19f1c9f0 100644
--- a/tests/unit/test_tag_index.py
+++ b/tests/unit/test_tag_index.py
@@ -30,84 +30,78 @@ def _standalone_file_store_with_tag_index(**kwargs) -> LocalFileStore:
return LocalFileStore(tag_index="default", **kwargs)
-def test_tag_normalization_and_bidirectional_mutations() -> None:
+@pytest.mark.asyncio
+async def test_tag_normalization_and_bidirectional_mutations() -> None:
"""Normalize FileNode tags and keep both lookup directions consistent."""
- async def run() -> None:
- index = LocalTagIndex(max_tags_per_file=3)
- await index.start()
- await index.upsert_nodes([_node("daily/a.md", ["Python Tag", "PYTHON TAG", "C++", ".NET", "ignored"])])
+ index = LocalTagIndex(max_tags_per_file=3)
+ await index.start()
+ await index.upsert_nodes([_node("daily/a.md", ["Python Tag", "PYTHON TAG", "C++", ".NET", "ignored"])])
- assert await index.tags_for_path("daily/a.md") == ["python_tag", "c++", ".net"]
- assert await index.paths_for_tags(["PYTHON TAG"]) == ["daily/a.md"]
- assert index.tag_to_paths == {
- "python_tag": {"daily/a.md"},
- "c++": {"daily/a.md"},
- ".net": {"daily/a.md"},
- }
+ assert await index.tags_for_path("daily/a.md") == ["python_tag", "c++", ".net"]
+ assert await index.paths_for_tags(["PYTHON TAG"]) == ["daily/a.md"]
+ assert index.tag_to_paths == {
+ "python_tag": {"daily/a.md"},
+ "c++": {"daily/a.md"},
+ ".net": {"daily/a.md"},
+ }
- await index.upsert_nodes([_node("daily/a.md", ["ReMe"])])
- assert await index.tags_for_path("daily/a.md") == ["reme"]
- assert set(index.tag_to_paths) == {"reme"}
+ await index.upsert_nodes([_node("daily/a.md", ["ReMe"])])
+ assert await index.tags_for_path("daily/a.md") == ["reme"]
+ assert set(index.tag_to_paths) == {"reme"}
- await index.delete(["daily/a.md", "daily/missing.md"])
- assert await index.tags_for_path("daily/a.md") == []
- assert not index.tag_to_paths
- await index.close()
-
- asyncio.run(run())
+ await index.delete(["daily/a.md", "daily/missing.md"])
+ assert await index.tags_for_path("daily/a.md") == []
+ assert not index.tag_to_paths
+ await index.close()
-def test_rebuild_is_atomic_and_supports_all_or_any_queries() -> None:
+@pytest.mark.asyncio
+async def test_rebuild_is_atomic_and_supports_all_or_any_queries() -> None:
"""Publish complete rebuilds atomically and support intersection and union lookup."""
- async def run() -> None:
- index = LocalTagIndex()
- await index.rebuild(
- [
- _node("daily/a.md", ["python", "reme"]),
- _node("digest/b.md", ["python"]),
- _node("digest/untagged.md"),
- ],
- )
+ index = LocalTagIndex()
+ await index.rebuild(
+ [
+ _node("daily/a.md", ["python", "reme"]),
+ _node("digest/b.md", ["python"]),
+ _node("digest/untagged.md"),
+ ],
+ )
- assert await index.paths_for_tags(["python", "reme"]) == ["daily/a.md"]
- assert await index.paths_for_tags(["python", "reme"], match_all=False) == [
- "daily/a.md",
- "digest/b.md",
- ]
- assert "digest/untagged.md" not in index.path_to_tags
+ assert await index.paths_for_tags(["python", "reme"]) == ["daily/a.md"]
+ assert await index.paths_for_tags(["python", "reme"], match_all=False) == [
+ "daily/a.md",
+ "digest/b.md",
+ ]
+ assert "digest/untagged.md" not in index.path_to_tags
- before_paths = dict(index.path_to_tags)
- before_tags = {tag: set(paths) for tag, paths in index.tag_to_paths.items()}
- with pytest.raises(ValueError, match="Invalid workspace-relative"):
- await index.rebuild([_node("daily/new.md", ["new"]), _node("../escape.md", ["invalid"])])
- assert index.path_to_tags == before_paths
- assert index.tag_to_paths == before_tags
-
- asyncio.run(run())
+ before_paths = dict(index.path_to_tags)
+ before_tags = {tag: set(paths) for tag, paths in index.tag_to_paths.items()}
+ with pytest.raises(ValueError, match="Invalid workspace-relative"):
+ await index.rebuild([_node("daily/new.md", ["new"]), _node("../escape.md", ["invalid"])])
+ assert index.path_to_tags == before_paths
+ assert index.tag_to_paths == before_tags
-def test_queries_are_not_truncated_by_per_file_tag_limit() -> None:
+@pytest.mark.asyncio
+async def test_queries_are_not_truncated_by_per_file_tag_limit() -> None:
"""Apply the count limit to indexed files without dropping lookup conditions."""
- async def run() -> None:
- index = LocalTagIndex(max_tags_per_file=2, max_tag_length=8)
- await index.rebuild(
- [
- _node("daily/a.md", ["a", "b"]),
- _node("daily/c.md", ["c"]),
- ],
- )
+ index = LocalTagIndex(max_tags_per_file=2, max_tag_length=8)
+ await index.rebuild(
+ [
+ _node("daily/a.md", ["a", "b"]),
+ _node("daily/c.md", ["c"]),
+ ],
+ )
- assert await index.paths_for_tags(["a", "b", "c"]) == []
- assert await index.paths_for_tags(["a", "b", "c"], match_all=False) == [
- "daily/a.md",
- "daily/c.md",
- ]
- assert index.normalize_query_tags([" A ", "B", "c c", "a", "too-long-tag", " "]) == ["a", "b", "c_c"]
-
- asyncio.run(run())
+ assert await index.paths_for_tags(["a", "b", "c"]) == []
+ assert await index.paths_for_tags(["a", "b", "c"], match_all=False) == [
+ "daily/a.md",
+ "daily/c.md",
+ ]
+ assert index.normalize_query_tags([" A ", "B", "c c", "a", "too-long-tag", " "]) == ["a", "b", "c_c"]
def test_list_tags_paginates_and_applies_default_sort_orders() -> None:
@@ -315,25 +309,23 @@ def test_file_store_without_tag_index_name_disables_tag_index() -> None:
@pytest.mark.parametrize("tag_index_name", ["", "custom", "missing"])
-def test_file_store_resolves_configured_tag_index(tag_index_name: str, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_file_store_resolves_configured_tag_index(tag_index_name: str, tmp_path: Path) -> None:
"""Resolve tag indexes by configured component name and fail on missing names."""
- async def run() -> None:
- context = ApplicationContext(workspace_dir=str(tmp_path))
- index = LocalTagIndex(name="custom", tag_key="keywords")
- context.components = {ComponentEnum.TAG_INDEX: {"custom": index}}
- store = LocalFileStore(app_context=context, embedding_store="", tag_index=tag_index_name)
+ context = ApplicationContext(workspace_dir=str(tmp_path))
+ index = LocalTagIndex(name="custom", tag_key="keywords")
+ context.components = {ComponentEnum.TAG_INDEX: {"custom": index}}
+ store = LocalFileStore(app_context=context, embedding_store="", tag_index=tag_index_name)
- if tag_index_name == "missing":
- with pytest.raises(ValueError, match="tag_index 'missing' not found"):
- await store.start()
- assert not store.is_started
- else:
- await store._resolve_bindings()
- assert store.tag_index is (index if tag_index_name else None)
- assert store.tag_index_enabled is bool(tag_index_name)
-
- asyncio.run(run())
+ if tag_index_name == "missing":
+ with pytest.raises(ValueError, match="tag_index 'missing' not found"):
+ await store.start()
+ assert not store.is_started
+ else:
+ await store._resolve_bindings()
+ assert store.tag_index is (index if tag_index_name else None)
+ assert store.tag_index_enabled is bool(tag_index_name)
def test_file_store_updates_tag_index_from_file_nodes(monkeypatch, tmp_path: Path) -> None:
@@ -376,207 +368,193 @@ def test_file_store_updates_tag_index_from_file_nodes(monkeypatch, tmp_path: Pat
asyncio.run(run())
-def test_tag_failures_do_not_block_other_indexes_and_retry_rebuild(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_tag_failures_do_not_block_other_indexes_and_retry_rebuild(monkeypatch, tmp_path: Path) -> None:
"""Keep core indexes writable and rebuild tags on the next mutation after a failed recovery."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- assert store.tag_index_enabled
- original_rebuild = store.tag_index.rebuild
+ monkeypatch.chdir(tmp_path)
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ assert store.tag_index_enabled
+ original_rebuild = store.tag_index.rebuild
- async def fail_incremental(_nodes) -> None:
- raise RuntimeError("incremental tag failure")
+ async def fail_incremental(_nodes) -> None:
+ raise RuntimeError("incremental tag failure")
- async def fail_rebuild(_nodes) -> None:
- raise RuntimeError("tag rebuild failure")
+ async def fail_rebuild(_nodes) -> None:
+ raise RuntimeError("tag rebuild failure")
- monkeypatch.setattr(store.tag_index, "upsert_nodes", fail_incremental)
- monkeypatch.setattr(store.tag_index, "rebuild", fail_rebuild)
+ monkeypatch.setattr(store.tag_index, "upsert_nodes", fail_incremental)
+ monkeypatch.setattr(store.tag_index, "rebuild", fail_rebuild)
- first_chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
- await store.upsert([(_node("daily/a.md", ["alpha"]), [first_chunk])])
+ first_chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
+ await store.upsert([(_node("daily/a.md", ["alpha"]), [first_chunk])])
- assert [node.path for node in await store.get_nodes()] == ["daily/a.md"]
- assert "chunk-a" in store.file_chunks
- assert "chunk-a" in store.keyword_index.document_ids
- assert store._tag_index_rebuild_required is True
+ assert [node.path for node in await store.get_nodes()] == ["daily/a.md"]
+ assert "chunk-a" in store.file_chunks
+ assert "chunk-a" in store.keyword_index.document_ids
+ assert store._tag_index_rebuild_required is True
- monkeypatch.setattr(store.tag_index, "rebuild", original_rebuild)
- second_chunk = _chunk("chunk-b", "digest/b.md", "beta memory")
- await store.upsert([(_node("digest/b.md", ["beta"]), [second_chunk])])
+ monkeypatch.setattr(store.tag_index, "rebuild", original_rebuild)
+ second_chunk = _chunk("chunk-b", "digest/b.md", "beta memory")
+ await store.upsert([(_node("digest/b.md", ["beta"]), [second_chunk])])
- assert store._tag_index_rebuild_required is False
- assert await store.tag_index.paths_for_tags(["alpha"]) == ["daily/a.md"]
- assert await store.tag_index.paths_for_tags(["beta"]) == ["digest/b.md"]
- assert {"chunk-a", "chunk-b"}.issubset(store.keyword_index.document_ids)
- await store.close()
-
- asyncio.run(run())
+ assert store._tag_index_rebuild_required is False
+ assert await store.tag_index.paths_for_tags(["alpha"]) == ["daily/a.md"]
+ assert await store.tag_index.paths_for_tags(["beta"]) == ["digest/b.md"]
+ assert {"chunk-a", "chunk-b"}.issubset(store.keyword_index.document_ids)
+ await store.close()
-def test_failed_tag_reconciliation_makes_queries_fail_closed(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_failed_tag_reconciliation_makes_queries_fail_closed(monkeypatch, tmp_path: Path) -> None:
"""Never expose stale tag matches while reconciliation is pending."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- assert store.tag_index_enabled
- await store.upsert([(_node("daily/a.md", ["old"]), [])])
+ monkeypatch.chdir(tmp_path)
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ assert store.tag_index_enabled
+ await store.upsert([(_node("daily/a.md", ["old"]), [])])
- async def fail(_items) -> None:
- raise RuntimeError("tag failure")
+ async def fail(_items) -> None:
+ raise RuntimeError("tag failure")
- monkeypatch.setattr(store.tag_index, "upsert_nodes", fail)
- monkeypatch.setattr(store.tag_index, "rebuild", fail)
- await store.upsert([(_node("daily/a.md", ["new"]), [])])
+ monkeypatch.setattr(store.tag_index, "upsert_nodes", fail)
+ monkeypatch.setattr(store.tag_index, "rebuild", fail)
+ await store.upsert([(_node("daily/a.md", ["new"]), [])])
- assert store._tag_index_rebuild_required is True
- assert store.tag_index.is_healthy is False
- assert await store.tag_index.paths_for_tags(["old"]) == []
- assert await store.tag_index.paths_for_tags(["new"]) == []
- assert await store.tag_index.tags_for_path("daily/a.md") == []
- await store.close()
-
- asyncio.run(run())
+ assert store._tag_index_rebuild_required is True
+ assert store.tag_index.is_healthy is False
+ assert await store.tag_index.paths_for_tags(["old"]) == []
+ assert await store.tag_index.paths_for_tags(["new"]) == []
+ assert await store.tag_index.tags_for_path("daily/a.md") == []
+ await store.close()
-def test_tag_rebuild_graph_read_failure_does_not_block_upsert(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_tag_rebuild_graph_read_failure_does_not_block_upsert(monkeypatch, tmp_path: Path) -> None:
"""Keep core indexes consistent if the optional tag repair cannot read the graph snapshot."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- assert store.tag_index_enabled
- assert store.file_graph is not None
- store._tag_index_rebuild_required = True
- original_get_nodes = store.file_graph.get_nodes
+ monkeypatch.chdir(tmp_path)
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ assert store.tag_index_enabled
+ assert store.file_graph is not None
+ store._tag_index_rebuild_required = True
+ original_get_nodes = store.file_graph.get_nodes
- async def fail_full_snapshot(paths=None):
- if paths is None:
- raise RuntimeError("graph snapshot failure")
- return await original_get_nodes(paths)
+ async def fail_full_snapshot(paths=None):
+ if paths is None:
+ raise RuntimeError("graph snapshot failure")
+ return await original_get_nodes(paths)
- monkeypatch.setattr(store.file_graph, "get_nodes", fail_full_snapshot)
- chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
- await store.upsert([(_node("daily/a.md", ["alpha"]), [chunk])])
+ monkeypatch.setattr(store.file_graph, "get_nodes", fail_full_snapshot)
+ chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
+ await store.upsert([(_node("daily/a.md", ["alpha"]), [chunk])])
- assert "chunk-a" in store.file_chunks
- assert "chunk-a" in store.keyword_index.document_ids
- assert store._tag_index_rebuild_required is True
- assert store.tag_index.is_healthy is False
- await store.close()
-
- asyncio.run(run())
+ assert "chunk-a" in store.file_chunks
+ assert "chunk-a" in store.keyword_index.document_ids
+ assert store._tag_index_rebuild_required is True
+ assert store.tag_index.is_healthy is False
+ await store.close()
-def test_explicit_reindex_restores_tag_index(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_explicit_reindex_restores_tag_index(monkeypatch, tmp_path: Path) -> None:
"""The tag scope and all scope rebuild tags from the authoritative graph."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- assert store.tag_index_enabled
- await store.upsert(
- [
- (_node("daily/a.md", ["ReMe"]), []),
- (_node("daily/untagged.md"), []),
- ],
- )
+ monkeypatch.chdir(tmp_path)
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ assert store.tag_index_enabled
+ await store.upsert(
+ [
+ (_node("daily/a.md", ["ReMe"]), []),
+ (_node("daily/untagged.md"), []),
+ ],
+ )
- await store.tag_index.clear()
- assert await store.tag_index.paths_for_tags(["reme"]) == []
- assert await store.reindex("tag") == {"indexed": 1, "scope": "tag"}
- assert await store.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
+ await store.tag_index.clear()
+ assert await store.tag_index.paths_for_tags(["reme"]) == []
+ assert await store.reindex("tag") == {"indexed": 1, "scope": "tag"}
+ assert await store.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
- await store.tag_index.clear()
- result = await store.reindex("all")
- assert result["tag"] == {"indexed": 1, "scope": "tag"}
- assert await store.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
- await store.file_graph.upsert_nodes([_node("daily/a.md", ["new"], key="keywords")])
+ await store.tag_index.clear()
+ result = await store.reindex("all")
+ assert result["tag"] == {"indexed": 1, "scope": "tag"}
+ assert await store.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
+ await store.file_graph.upsert_nodes([_node("daily/a.md", ["new"], key="keywords")])
- store.tag_index.tag_key = "keywords"
- await store.reindex("tag")
+ store.tag_index.tag_key = "keywords"
+ await store.reindex("tag")
- assert await store.tag_index.paths_for_tags(["old"]) == []
- assert await store.tag_index.paths_for_tags(["new"]) == ["daily/a.md"]
- await store.close()
-
- asyncio.run(run())
+ assert await store.tag_index.paths_for_tags(["old"]) == []
+ assert await store.tag_index.paths_for_tags(["new"]) == ["daily/a.md"]
+ await store.close()
-def test_tag_delete_failures_do_not_block_core_deletion(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_tag_delete_failures_do_not_block_core_deletion(monkeypatch, tmp_path: Path) -> None:
"""Complete graph, chunk, and keyword deletion when tag deletion and recovery fail."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- assert store.tag_index_enabled
- chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
- await store.upsert([(_node("daily/a.md", ["alpha"]), [chunk])])
+ monkeypatch.chdir(tmp_path)
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ assert store.tag_index_enabled
+ chunk = _chunk("chunk-a", "daily/a.md", "alpha memory")
+ await store.upsert([(_node("daily/a.md", ["alpha"]), [chunk])])
- async def fail_delete(_paths) -> None:
- raise RuntimeError("incremental tag delete failure")
+ async def fail_delete(_paths) -> None:
+ raise RuntimeError("incremental tag delete failure")
- async def fail_rebuild(_nodes) -> None:
- raise RuntimeError("tag rebuild failure")
+ async def fail_rebuild(_nodes) -> None:
+ raise RuntimeError("tag rebuild failure")
- monkeypatch.setattr(store.tag_index, "delete", fail_delete)
- monkeypatch.setattr(store.tag_index, "rebuild", fail_rebuild)
- await store.delete("daily/a.md")
+ monkeypatch.setattr(store.tag_index, "delete", fail_delete)
+ monkeypatch.setattr(store.tag_index, "rebuild", fail_rebuild)
+ await store.delete("daily/a.md")
- assert await store.get_nodes() == []
- assert "chunk-a" not in store.file_chunks
- assert "chunk-a" not in store.keyword_index.document_ids
- assert store._tag_index_rebuild_required is True
- await store.close()
-
- asyncio.run(run())
+ assert await store.get_nodes() == []
+ assert "chunk-a" not in store.file_chunks
+ assert "chunk-a" not in store.keyword_index.document_ids
+ assert store._tag_index_rebuild_required is True
+ await store.close()
-def test_existing_markdown_chunker_supplies_frontmatter_tags(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_existing_markdown_chunker_supplies_frontmatter_tags(monkeypatch, tmp_path: Path) -> None:
"""Use the FileNode produced by the existing chunker without reading frontmatter again."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- note = tmp_path / "daily" / "a.md"
- note.parent.mkdir()
- note.write_text("---\nmemory_tags: [Python, ReMe]\n---\nbody\n", encoding="utf-8")
- node, chunks = await MarkdownFileChunker().chunk(note)
+ monkeypatch.chdir(tmp_path)
+ note = tmp_path / "daily" / "a.md"
+ note.parent.mkdir()
+ note.write_text("---\nmemory_tags: [Python, ReMe]\n---\nbody\n", encoding="utf-8")
+ node, chunks = await MarkdownFileChunker().chunk(note)
- store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await store.start()
- await store.upsert([(node, chunks)])
+ store = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await store.start()
+ await store.upsert([(node, chunks)])
- assert await store.tag_index.tags_for_path("daily/a.md") == ["python", "reme"]
- await store.close()
-
- asyncio.run(run())
+ assert await store.tag_index.tags_for_path("daily/a.md") == ["python", "reme"]
+ await store.close()
-def test_file_store_rebuilds_non_persistent_tag_index_from_graph(monkeypatch, tmp_path: Path) -> None:
+@pytest.mark.asyncio
+async def test_file_store_rebuilds_non_persistent_tag_index_from_graph(monkeypatch, tmp_path: Path) -> None:
"""Restore tag relationships from the persisted file graph on startup."""
- async def run() -> None:
- monkeypatch.chdir(tmp_path)
- first = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await first.start()
- await first.upsert([(_node("daily/a.md", ["ReMe"]), [])])
- await first.close()
+ monkeypatch.chdir(tmp_path)
+ first = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await first.start()
+ await first.upsert([(_node("daily/a.md", ["ReMe"]), [])])
+ await first.close()
- assert not list((tmp_path / "metadata").glob("tag_index/**/*"))
+ assert not list((tmp_path / "metadata").glob("tag_index/**/*"))
- restored = _standalone_file_store_with_tag_index(name="test", embedding_store="")
- await restored.start()
- assert await restored.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
- await restored.close()
-
- asyncio.run(run())
+ restored = _standalone_file_store_with_tag_index(name="test", embedding_store="")
+ await restored.start()
+ assert await restored.tag_index.paths_for_tags(["reme"]) == ["daily/a.md"]
+ await restored.close()
def test_default_config_enables_tag_index_with_explicit_key() -> None:
diff --git a/tests/unit/test_tokenizer.py b/tests/unit/test_tokenizer.py
index a2f57f69..623720e5 100644
--- a/tests/unit/test_tokenizer.py
+++ b/tests/unit/test_tokenizer.py
@@ -2,10 +2,12 @@
import asyncio
+import pytest
+
from reme.components.tokenizer import JiebaTokenizer, RegexTokenizer
-async def compare_tokenizers(texts: list[str], filter_stopwords: bool = False, name: str = ""):
+async def compare_tokenizers(texts: list[str], filter_stopwords: bool = False):
"""Compare both tokenizers on same input."""
jieba = JiebaTokenizer(filter_stopwords=filter_stopwords)
regex = RegexTokenizer(filter_stopwords=filter_stopwords)
@@ -16,123 +18,73 @@ async def compare_tokenizers(texts: list[str], filter_stopwords: bool = False, n
jieba_result = jieba.tokenize(texts)
regex_result = regex.tokenize(texts)
- print(f"\n--- {name} ---")
- print(f"输入: {texts}")
- print(f"Jieba: {jieba_result}")
- print(f"Regex: {regex_result}")
-
await jieba.close()
await regex.close()
return jieba_result, regex_result
-def test_basic_chinese():
- """Test basic Chinese text."""
-
- async def run():
- jieba_result, regex_result = await compare_tokenizers(
- ["我爱北京天安门", "今天天气很好"],
- name="纯中文",
- )
-
- assert "北京" in jieba_result[0] or "天安门" in jieba_result[0]
- assert "我" in regex_result[0]
- print("✓ test_basic_chinese passed")
-
- asyncio.run(run())
-
-
-def test_basic_english():
- """Test basic English text."""
-
- async def run():
- _, regex_result = await compare_tokenizers(
- ["I love Beijing very much"],
- name="英文",
- )
-
- assert "love" in regex_result[0]
- assert "beijing" in regex_result[0]
- print("✓ test_basic_english passed")
-
- asyncio.run(run())
-
-
-def test_mixed_chinese_english():
+@pytest.mark.asyncio
+async def test_mixed_chinese_english():
"""Test mixed Chinese-English text."""
- async def run():
- jieba_result, regex_result = await compare_tokenizers(
- ["我用 Python 学习 machine learning 和 iPhone15 Pro。"],
- name="中英混合",
- )
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我用 Python 学习 machine learning 和 iPhone15 Pro。"],
+ )
- assert "python" in jieba_result[0]
- assert "python" in regex_result[0]
- print("✓ test_mixed_chinese_english passed")
-
- asyncio.run(run())
+ assert "python" in jieba_result[0]
+ assert "python" in regex_result[0]
-def test_open_example():
+@pytest.mark.asyncio
+async def test_open_example():
"""Test the 'open' example."""
- async def run():
- jieba_result, regex_result = await compare_tokenizers(
- ["我觉得open很好呀,能分好次吗?"],
- name="'open' 案例",
- )
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我觉得open很好呀,能分好次吗?"],
+ )
- # open 保持完整
- assert "open" in jieba_result[0]
- assert "open" in regex_result[0]
+ # open 保持完整
+ assert "open" in jieba_result[0]
+ assert "open" in regex_result[0]
- # Regex 中文按字拆分
- assert "我" in regex_result[0]
- assert "很" in regex_result[0]
-
- print("✓ test_open_example passed")
-
- asyncio.run(run())
+ # Regex 中文按字拆分
+ assert "我" in regex_result[0]
+ assert "很" in regex_result[0]
-def test_with_stopwords():
+@pytest.mark.asyncio
+async def test_with_stopwords():
"""Test with stopwords filtering."""
- async def run():
- jieba_result, regex_result = await compare_tokenizers(
- ["我觉得open很好呀,能分好次吗?"],
- filter_stopwords=True,
- name="停用词过滤",
- )
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我觉得open很好呀,能分好次吗?"],
+ filter_stopwords=True,
+ )
- # 停用词被过滤
- assert "吗" not in jieba_result[0]
- assert "吗" not in regex_result[0]
- assert "的" not in jieba_result[0]
-
- print("✓ test_with_stopwords passed")
-
- asyncio.run(run())
+ # 停用词被过滤
+ assert "吗" not in jieba_result[0]
+ assert "吗" not in regex_result[0]
+ assert "的" not in jieba_result[0]
-def test_multiple_texts():
+@pytest.mark.asyncio
+async def test_multiple_texts():
"""Test multiple texts at once."""
- async def run():
- texts = [
- "我爱北京天安门",
- "I love Python programming",
- "今天学习 machine learning",
- ]
- jieba_result, regex_result = await compare_tokenizers(texts, name="多个文本")
+ texts = [
+ "我爱北京天安门",
+ "I love Beijing very much",
+ "今天学习 machine learning",
+ ]
+ jieba_result, regex_result = await compare_tokenizers(texts)
- assert len(jieba_result) == 3
- assert len(regex_result) == 3
- print("✓ test_multiple_texts passed")
-
- asyncio.run(run())
+ assert len(jieba_result) == 3
+ assert len(regex_result) == 3
+ assert "北京" in jieba_result[0] or "天安门" in jieba_result[0]
+ assert "我" in regex_result[0]
+ assert "love" in regex_result[1]
+ assert "beijing" in regex_result[1]
def test_tokenizer_lifecycle():
@@ -152,8 +104,6 @@ def test_tokenizer_lifecycle():
assert not tokenizer.is_started
assert len(tokenizer.stopwords) == 0
- print("✓ test_tokenizer_lifecycle passed")
-
asyncio.run(run())
@@ -166,16 +116,3 @@ def test_jieba_tokenizer_requires_start():
assert "Call start() first" in str(exc)
else:
raise AssertionError("expected RuntimeError when JiebaTokenizer is used before start()")
-
-
-if __name__ == "__main__":
- print("\n=== Tokenizer Tests ===")
- test_basic_chinese()
- test_basic_english()
- test_mixed_chinese_english()
- test_open_example()
- test_with_stopwords()
- test_multiple_texts()
- test_tokenizer_lifecycle()
- test_jieba_tokenizer_requires_start()
- print("\n所有测试通过!")
diff --git a/tests/unit/test_tushare.py b/tests/unit/test_tushare.py
deleted file mode 100644
index 2bbdbe4e..00000000
--- a/tests/unit/test_tushare.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""Focused tests for TuShare source selection."""
-
-# pylint: disable=protected-access
-
-import sys
-from types import SimpleNamespace
-
-from reme.utils.tushare import create_tushare_api
-
-
-def test_tushare_uses_configured_mirror_or_sdk_default(monkeypatch):
- """A mirror overrides the SDK endpoint while an empty setting leaves it unchanged."""
- api = SimpleNamespace(_DataApi__http_url="https://api.tushare.pro")
- monkeypatch.setitem(sys.modules, "tushare", SimpleNamespace(pro_api=lambda _token: api))
-
- monkeypatch.delenv("TUSHARE_MIRROR_URL", raising=False)
- assert create_tushare_api("token")._DataApi__http_url == "https://api.tushare.pro"
- assert api._DataApi__timeout == 600
-
- monkeypatch.setenv("TUSHARE_MIRROR_URL", "http://112.124.63.173:4000/tushare/")
- assert create_tushare_api("token")._DataApi__http_url == "http://112.124.63.173:4000/tushare"
diff --git a/tests/unit/test_wikilink_utils.py b/tests/unit/test_wikilink_utils.py
index 7a69cfff..361c2238 100644
--- a/tests/unit/test_wikilink_utils.py
+++ b/tests/unit/test_wikilink_utils.py
@@ -14,12 +14,13 @@ short ``[[x]]`` and no-ext ``[[topics/x]]`` are left alone by design.
# pylint: disable=protected-access
-import asyncio
-import os
+from contextlib import chdir as temp_chdir
import tempfile
import warnings
from pathlib import Path
+import pytest
+
from reme.components.file_store import LocalFileStore
from reme.schema import FileNode
from reme.utils.wikilink_handler import WikilinkHandler
@@ -28,22 +29,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Test helper: chdir to ``path`` on enter, restore previous cwd on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
async def _store_with(files: dict[str, str]) -> LocalFileStore:
"""LocalFileStore seeded with (rel → content) files: written to disk
AND registered in the file_graph with wikilinks parsed from the body.
@@ -77,144 +62,121 @@ async def _empty_store() -> LocalFileStore:
return store
-def test_retarget_exact_full_path_match():
+@pytest.mark.asyncio
+async def test_retarget_exact_full_path_match():
"""[[topics/Alice.md]] → [[people/Alice.md]]; non-matching links untouched."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with(
- {
- "note.md": "See [[topics/Alice.md]] and [[topics/Bob.md]].",
- "people/Alice.md": "# Alice",
- },
- )
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert "error" not in payload
- assert payload["links_changed"] == 1
- assert payload["files_touched"] == 1
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md]]" in body
- assert "[[topics/Alice.md]]" not in body
- assert "[[topics/Bob.md]]" in body
- await store.close()
- print("✓ test_retarget_exact_full_path_match passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with(
+ {
+ "note.md": "See [[topics/Alice.md]] and [[topics/Bob.md]].",
+ "people/Alice.md": "# Alice",
+ },
+ )
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert "error" not in payload
+ assert payload["links_changed"] == 1
+ assert payload["files_touched"] == 1
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md]]" in body
+ assert "[[topics/Alice.md]]" not in body
+ assert "[[topics/Bob.md]]" in body
+ await store.close()
-def test_retarget_short_and_no_ext_forms_ignored():
+@pytest.mark.asyncio
+async def test_retarget_short_and_no_ext_forms_ignored():
"""Short-form [[Alice]] and no-ext [[topics/Alice]] are NOT matched."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- body_in = "See [[Alice]] and [[topics/Alice]] but also [[topics/Alice.md]]."
- store = await _store_with(
- {
- "note.md": body_in,
- "people/Alice.md": "# Alice",
- },
- )
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 1 # only the full-path form
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md]]" in body
- assert "[[Alice]]" in body # short form untouched
- assert "[[topics/Alice]]" in body # no-ext form untouched
- await store.close()
- print("✓ test_retarget_short_and_no_ext_forms_ignored passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ body_in = "See [[Alice]] and [[topics/Alice]] but also [[topics/Alice.md]]."
+ store = await _store_with(
+ {
+ "note.md": body_in,
+ "people/Alice.md": "# Alice",
+ },
+ )
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 1 # only the full-path form
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md]]" in body
+ assert "[[Alice]]" in body # short form untouched
+ assert "[[topics/Alice]]" in body # no-ext form untouched
+ await store.close()
-def test_retarget_anchor_preserved():
+@pytest.mark.asyncio
+async def test_retarget_anchor_preserved():
"""`[[topics/Alice.md#intro]]` → `[[people/Alice.md#intro]]` (anchor kept verbatim)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with({"note.md": "Jump to [[topics/Alice.md#intro]] please."})
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 1
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md#intro]]" in body
- await store.close()
- print("✓ test_retarget_anchor_preserved passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with({"note.md": "Jump to [[topics/Alice.md#intro]] please."})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 1
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md#intro]]" in body
+ await store.close()
-def test_retarget_alias_preserved():
+@pytest.mark.asyncio
+async def test_retarget_alias_preserved():
"""`[[topics/Alice.md|Display Name]]` → `[[people/Alice.md|Display Name]]`."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with({"note.md": "Meet [[topics/Alice.md|Alice the Architect]]."})
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 1
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md|Alice the Architect]]" in body
- await store.close()
- print("✓ test_retarget_alias_preserved passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with({"note.md": "Meet [[topics/Alice.md|Alice the Architect]]."})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 1
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md|Alice the Architect]]" in body
+ await store.close()
-def test_retarget_anchor_and_alias_together():
+@pytest.mark.asyncio
+async def test_retarget_anchor_and_alias_together():
"""`[[A.md#h|disp]]` → `[[B.md#h|disp]]` keeps both suffixes."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with({"note.md": "See [[topics/Alice.md#bio|her bio]] now."})
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 1
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md#bio|her bio]]" in body
- await store.close()
- print("✓ test_retarget_anchor_and_alias_together passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with({"note.md": "See [[topics/Alice.md#bio|her bio]] now."})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 1
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md#bio|her bio]]" in body
+ await store.close()
-def test_retarget_image_marker_preserved():
+@pytest.mark.asyncio
+async def test_retarget_image_marker_preserved():
"""`![[topics/diagram.md]]` (embed) keeps its `!` prefix on rewrite."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with({"note.md": "Inline embed: ![[topics/diagram.md]] here."})
- payload = await WikilinkHandler.retarget_links(store, src="topics/diagram.md", dst="diagrams/diagram.md")
- assert payload["links_changed"] == 1
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "![[diagrams/diagram.md]]" in body
- await store.close()
- print("✓ test_retarget_image_marker_preserved passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with({"note.md": "Inline embed: ![[topics/diagram.md]] here."})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/diagram.md", dst="diagrams/diagram.md")
+ assert payload["links_changed"] == 1
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "![[diagrams/diagram.md]]" in body
+ await store.close()
-def test_retarget_preserves_surrounding_relation_text():
+@pytest.mark.asyncio
+async def test_retarget_preserves_surrounding_relation_text():
"""Retargeting changes only the inner wikilink target."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- body_in = (
- "colleague:: [[topics/Alice.md]]\n" + "She is the [负责:: [[topics/Alice.md]]] for the migration.\n"
- )
- store = await _store_with({"note.md": body_in})
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 2
- body = (root / "note.md").read_text(encoding="utf-8")
- assert "colleague:: [[people/Alice.md]]" in body
- assert "[负责:: [[people/Alice.md]]]" in body
- await store.close()
- print("✓ test_retarget_preserves_surrounding_relation_text passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ body_in = "colleague:: [[topics/Alice.md]]\n" + "She is the [负责:: [[topics/Alice.md]]] for the migration.\n"
+ store = await _store_with({"note.md": body_in})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 2
+ body = (root / "note.md").read_text(encoding="utf-8")
+ assert "colleague:: [[people/Alice.md]]" in body
+ assert "[负责:: [[people/Alice.md]]]" in body
+ await store.close()
def test_markdown_links_are_ignored():
@@ -239,184 +201,139 @@ def test_markdown_links_are_ignored():
assert rewritten == f"{markdown_links} [[archive/example.md#L9]]"
-def test_retarget_multiple_files_aggregate_counts():
+@pytest.mark.asyncio
+async def test_retarget_multiple_files_aggregate_counts():
"""links_changed sums across files; by_file lists per-file counts."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "a.md": "[[topics/Alice.md]] then [[topics/Alice.md]]",
- "sub/b.md": "[[topics/Alice.md]] and [[topics/Bob.md]]",
- },
- )
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
- assert payload["links_changed"] == 3
- assert payload["files_touched"] == 2
- by_file = {row["path"]: row["count"] for row in payload["by_file"]}
- assert by_file == {"a.md": 2, "sub/b.md": 1}
- await store.close()
- print("✓ test_retarget_multiple_files_aggregate_counts passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "a.md": "[[topics/Alice.md]] then [[topics/Alice.md]]",
+ "sub/b.md": "[[topics/Alice.md]] and [[topics/Bob.md]]",
+ },
+ )
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="people/Alice.md")
+ assert payload["links_changed"] == 3
+ assert payload["files_touched"] == 2
+ by_file = {row["path"]: row["count"] for row in payload["by_file"]}
+ assert by_file == {"a.md": 2, "sub/b.md": 1}
+ await store.close()
-def test_retarget_dry_run_does_not_write():
+@pytest.mark.asyncio
+async def test_retarget_dry_run_does_not_write():
"""dry_run=True reports counts but leaves files on disk unchanged."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- original = "See [[topics/Alice.md]]."
- store = await _store_with({"note.md": original})
- payload = await WikilinkHandler.retarget_links(
- store,
- src="topics/Alice.md",
- dst="people/Alice.md",
- dry_run=True,
- )
- assert payload["dry_run"] is True
- assert payload["links_changed"] == 1
- assert payload["files_touched"] == 1
- assert (root / "note.md").read_text(encoding="utf-8") == original
- await store.close()
- print("✓ test_retarget_dry_run_does_not_write passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ original = "See [[topics/Alice.md]]."
+ store = await _store_with({"note.md": original})
+ payload = await WikilinkHandler.retarget_links(
+ store,
+ src="topics/Alice.md",
+ dst="people/Alice.md",
+ dry_run=True,
+ )
+ assert payload["dry_run"] is True
+ assert payload["links_changed"] == 1
+ assert payload["files_touched"] == 1
+ assert (root / "note.md").read_text(encoding="utf-8") == original
+ await store.close()
-def test_retarget_scope_limits_sweep():
+@pytest.mark.asyncio
+async def test_retarget_scope_limits_sweep():
"""Files outside `scope` are not visited even if they contain matches."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- root = Path(tmp)
- store = await _store_with(
- {
- "in_scope/note.md": "See [[topics/Alice.md]].",
- "outside/note.md": "Also [[topics/Alice.md]].",
- },
- )
- payload = await WikilinkHandler.retarget_links(
- store,
- src="topics/Alice.md",
- dst="people/Alice.md",
- scope="in_scope",
- )
- assert payload["links_changed"] == 1
- assert payload["files_touched"] == 1
- in_body = (root / "in_scope/note.md").read_text(encoding="utf-8")
- out_body = (root / "outside/note.md").read_text(encoding="utf-8")
- assert "[[people/Alice.md]]" in in_body
- assert "[[topics/Alice.md]]" in out_body # untouched
- await store.close()
- print("✓ test_retarget_scope_limits_sweep passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ root = Path(tmp)
+ store = await _store_with(
+ {
+ "in_scope/note.md": "See [[topics/Alice.md]].",
+ "outside/note.md": "Also [[topics/Alice.md]].",
+ },
+ )
+ payload = await WikilinkHandler.retarget_links(
+ store,
+ src="topics/Alice.md",
+ dst="people/Alice.md",
+ scope="in_scope",
+ )
+ assert payload["links_changed"] == 1
+ assert payload["files_touched"] == 1
+ in_body = (root / "in_scope/note.md").read_text(encoding="utf-8")
+ out_body = (root / "outside/note.md").read_text(encoding="utf-8")
+ assert "[[people/Alice.md]]" in in_body
+ assert "[[topics/Alice.md]]" in out_body # untouched
+ await store.close()
-def test_retarget_src_eq_dst_is_noop():
+@pytest.mark.asyncio
+async def test_retarget_src_eq_dst_is_noop():
"""src == dst: no scan needed, returns zero counts."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with({"note.md": "See [[topics/Alice.md]]."})
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="topics/Alice.md")
- assert payload["links_changed"] == 0
- assert payload["files_touched"] == 0
- await store.close()
- print("✓ test_retarget_src_eq_dst_is_noop passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with({"note.md": "See [[topics/Alice.md]]."})
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst="topics/Alice.md")
+ assert payload["links_changed"] == 0
+ assert payload["files_touched"] == 0
+ await store.close()
-def test_retarget_empty_src_or_dst_errors():
+@pytest.mark.asyncio
+async def test_retarget_empty_src_or_dst_errors():
"""Empty src or dst produces an error payload."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _empty_store()
- for kwargs in ({"src": "", "dst": "people/Alice.md"}, {"src": "topics/Alice.md", "dst": ""}):
- payload = await WikilinkHandler.retarget_links(store, **kwargs)
- assert "error" in payload, f"expected error for {kwargs}"
- await store.close()
- print("✓ test_retarget_empty_src_or_dst_errors passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _empty_store()
+ for kwargs in ({"src": "", "dst": "people/Alice.md"}, {"src": "topics/Alice.md", "dst": ""}):
+ payload = await WikilinkHandler.retarget_links(store, **kwargs)
+ assert "error" in payload, f"expected error for {kwargs}"
+ await store.close()
-def test_retarget_dst_with_forbidden_chars_errors():
+@pytest.mark.asyncio
+async def test_retarget_dst_with_forbidden_chars_errors():
"""``dst`` containing ``[ ] # |`` or newline lands as an error payload."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _empty_store()
- for bad in ["people/Alice#anchor", "people/Alice|alias", "p/[A].md", "p/A]]"]:
- payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst=bad)
- assert "error" in payload, f"expected error for dst={bad!r}, got {payload}"
- await store.close()
- print("✓ test_retarget_dst_with_forbidden_chars_errors passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _empty_store()
+ for bad in ["people/Alice#anchor", "people/Alice|alias", "p/[A].md", "p/A]]"]:
+ payload = await WikilinkHandler.retarget_links(store, src="topics/Alice.md", dst=bad)
+ assert "error" in payload, f"expected error for dst={bad!r}, got {payload}"
+ await store.close()
-def test_retarget_absolute_path_rejected():
+@pytest.mark.asyncio
+async def test_retarget_absolute_path_rejected():
"""Absolute paths in src or dst are rejected (must be relative to the workspace)."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _empty_store()
- for kwargs in (
- {"src": "/abs/old.md", "dst": "people/Alice.md"},
- {"src": "topics/Alice.md", "dst": "/abs/new.md"},
- ):
- payload = await WikilinkHandler.retarget_links(store, **kwargs)
- assert "error" in payload, f"expected error for {kwargs}"
- await store.close()
- print("✓ test_retarget_absolute_path_rejected passed")
-
- asyncio.run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _empty_store()
+ for kwargs in (
+ {"src": "/abs/old.md", "dst": "people/Alice.md"},
+ {"src": "topics/Alice.md", "dst": "/abs/new.md"},
+ ):
+ payload = await WikilinkHandler.retarget_links(store, **kwargs)
+ assert "error" in payload, f"expected error for {kwargs}"
+ await store.close()
-def test_find_inbound_counts_references():
+@pytest.mark.asyncio
+async def test_find_inbound_counts_references():
"""find_inbound reports per-file counts and excludes self-references."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _store_with(
- {
- "a.md": "[[topics/Alice.md]] and [[topics/Alice.md]] again",
- "b.md": "see [[topics/Alice.md]]",
- "topics/Alice.md": "self-ref [[topics/Alice.md]] should not count",
- },
- )
- payload = await WikilinkHandler.find_inbound(store, target="topics/Alice.md")
- assert payload["files_touched"] == 2
- assert payload["links_total"] == 3
- by_file = {row["path"]: row["count"] for row in payload["by_file"]}
- assert by_file == {"a.md": 2, "b.md": 1}
- await store.close()
- print("✓ test_find_inbound_counts_references passed")
-
- asyncio.run(run())
-
-
-if __name__ == "__main__":
- print("\n=== wikilink helper tests ===")
- test_retarget_exact_full_path_match()
- test_retarget_short_and_no_ext_forms_ignored()
- test_retarget_anchor_preserved()
- test_retarget_alias_preserved()
- test_retarget_anchor_and_alias_together()
- test_retarget_image_marker_preserved()
- test_retarget_preserves_surrounding_relation_text()
- test_markdown_links_are_ignored()
- test_retarget_multiple_files_aggregate_counts()
- test_retarget_dry_run_does_not_write()
- test_retarget_scope_limits_sweep()
- test_retarget_src_eq_dst_is_noop()
- test_retarget_empty_src_or_dst_errors()
- test_retarget_dst_with_forbidden_chars_errors()
- test_retarget_absolute_path_rejected()
- test_find_inbound_counts_references()
- print("\n所有测试通过!")
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _store_with(
+ {
+ "a.md": "[[topics/Alice.md]] and [[topics/Alice.md]] again",
+ "b.md": "see [[topics/Alice.md]]",
+ "topics/Alice.md": "self-ref [[topics/Alice.md]] should not count",
+ },
+ )
+ payload = await WikilinkHandler.find_inbound(store, target="topics/Alice.md")
+ assert payload["files_touched"] == 2
+ assert payload["links_total"] == 3
+ by_file = {row["path"]: row["count"] for row in payload["by_file"]}
+ assert by_file == {"a.md": 2, "b.md": 1}
+ await store.close()
diff --git a/tests/unit/test_workspace_web_steps.py b/tests/unit/test_workspace_web_steps.py
index c88e4f7f..39254470 100644
--- a/tests/unit/test_workspace_web_steps.py
+++ b/tests/unit/test_workspace_web_steps.py
@@ -5,6 +5,8 @@ import os
from datetime import datetime
from zoneinfo import ZoneInfo
+import pytest
+
from reme.components.agent_wrapper import BaseAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.components.runtime_context import RuntimeContext
@@ -32,139 +34,127 @@ class _StreamingAgent(BaseAgentWrapper):
yield StreamChunk(chunk_type=ChunkEnum.REPLY_END, chunk="", session_id="session-new")
-def test_save_step_preserves_complete_markdown(tmp_path):
+@pytest.mark.asyncio
+async def test_save_step_preserves_complete_markdown(tmp_path):
"""The editor save endpoint writes frontmatter and body verbatim."""
- async def run():
- target = tmp_path / "daily" / "note.md"
- target.parent.mkdir(parents=True)
- target.write_text("old", encoding="utf-8")
- expected = datetime.fromtimestamp(target.stat().st_mtime).isoformat()
- content = "---\nname: note\ncustom: keep\n---\n\n# Updated\n"
+ target = tmp_path / "daily" / "note.md"
+ target.parent.mkdir(parents=True)
+ target.write_text("old", encoding="utf-8")
+ expected = datetime.fromtimestamp(target.stat().st_mtime).isoformat()
+ content = "---\nname: note\ncustom: keep\n---\n\n# Updated\n"
- response = await SaveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
- path="daily/note.md",
- content=content,
- expected_mtime=expected,
- )
+ response = await SaveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
+ path="daily/note.md",
+ content=content,
+ expected_mtime=expected,
+ )
- assert response.success is True
- assert target.read_text(encoding="utf-8") == content
- assert response.metadata["path"] == "daily/note.md"
- assert response.metadata["mtime"]
-
- asyncio.run(run())
+ assert response.success is True
+ assert target.read_text(encoding="utf-8") == content
+ assert response.metadata["path"] == "daily/note.md"
+ assert response.metadata["mtime"]
-def test_load_step_returns_complete_content_and_mtime(tmp_path):
+@pytest.mark.asyncio
+async def test_load_step_returns_complete_content_and_mtime(tmp_path):
"""Editor loads are complete and include the concurrency token for save."""
- async def run():
- content = "# Large enough\n" + ("memory line\n" * 5000)
- target = tmp_path / "note.md"
- target.write_text(content, encoding="utf-8")
+ content = "# Large enough\n" + ("memory line\n" * 5000)
+ target = tmp_path / "note.md"
+ target.write_text(content, encoding="utf-8")
- response = await LoadStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(path="note.md")
+ response = await LoadStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(path="note.md")
- assert response.success is True
- assert response.answer == content
- assert response.metadata["size"] == target.stat().st_size
- assert response.metadata["mtime"]
-
- asyncio.run(run())
+ assert response.success is True
+ assert response.answer == content
+ assert response.metadata["size"] == target.stat().st_size
+ assert response.metadata["mtime"]
-def test_load_step_rejects_file_larger_than_editor_limit(tmp_path):
+@pytest.mark.asyncio
+async def test_load_step_rejects_file_larger_than_editor_limit(tmp_path):
"""Oversized files fail explicitly instead of returning editable truncation."""
- async def run():
- (tmp_path / "note.md").write_text("0123456789", encoding="utf-8")
+ (tmp_path / "note.md").write_text("0123456789", encoding="utf-8")
- response = await LoadStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
- path="note.md",
- max_bytes=5,
- )
+ response = await LoadStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
+ path="note.md",
+ max_bytes=5,
+ )
- assert response.success is False
- assert response.metadata["code"] == "file_too_large"
-
- asyncio.run(run())
+ assert response.success is False
+ assert response.metadata["code"] == "file_too_large"
-def test_save_step_rejects_external_change(tmp_path):
+@pytest.mark.asyncio
+async def test_save_step_rejects_external_change(tmp_path):
"""An obsolete mtime cannot overwrite a file changed by another editor."""
- async def run():
- target = tmp_path / "note.md"
- target.write_text("first", encoding="utf-8")
- expected = datetime.fromtimestamp(target.stat().st_mtime).isoformat()
- target.write_text("external", encoding="utf-8")
- newer = target.stat().st_mtime + 2
- os.utime(target, (newer, newer))
+ target = tmp_path / "note.md"
+ target.write_text("first", encoding="utf-8")
+ expected = datetime.fromtimestamp(target.stat().st_mtime).isoformat()
+ target.write_text("external", encoding="utf-8")
+ newer = target.stat().st_mtime + 2
+ os.utime(target, (newer, newer))
- response = await SaveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
- path="note.md",
- content="browser edit",
- expected_mtime=expected,
- )
+ response = await SaveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
+ path="note.md",
+ content="browser edit",
+ expected_mtime=expected,
+ )
- assert response.success is False
- assert response.metadata["code"] == "file_conflict"
- assert target.read_text(encoding="utf-8") == "external"
-
- asyncio.run(run())
+ assert response.success is False
+ assert response.metadata["code"] == "file_conflict"
+ assert target.read_text(encoding="utf-8") == "external"
-def test_chat_step_streams_rich_chunks_and_resumes_session(tmp_path):
+@pytest.mark.asyncio
+async def test_chat_step_streams_rich_chunks_and_resumes_session(tmp_path):
"""Web chat keeps StreamChunk metadata and exposes only read-only tools."""
- async def run():
- app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
- agent = _StreamingAgent(app_context=app_context)
- queue = asyncio.Queue()
- context = RuntimeContext(stream_queue=queue, query="hello", session_id="session-old")
+ app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
+ agent = _StreamingAgent(app_context=app_context)
+ queue = asyncio.Queue()
+ context = RuntimeContext(stream_queue=queue, query="hello", session_id="session-old")
- response = await ChatStep(agent_wrapper=agent, app_context=app_context)(context)
+ response = await ChatStep(agent_wrapper=agent, app_context=app_context)(context)
- chunks = [await queue.get(), await queue.get(), await queue.get()]
- assert response.answer == "hello"
- assert chunks[0].tool_call_name == "search"
- assert chunks[1].session_id == "session-new"
- assert chunks[2].metadata["answer"] == "hello"
- assert agent.reply_kwargs["resume"] == "session-old"
- assert agent.reply_kwargs["job_tools"] == [
- "search",
- "list",
- "read",
- "read_image",
- "frontmatter_read",
- "stat",
- "traverse",
- ]
- assert agent.reply_kwargs["builtin_tools"] == []
- system_prompt = agent.reply_kwargs["system_prompt"]
- assert f"Current date: {datetime.now(ZoneInfo('Asia/Shanghai')).date().isoformat()}" in system_prompt
- assert f"Current working directory: {tmp_path.resolve()}" in system_prompt
-
- asyncio.run(run())
+ chunks = [await queue.get(), await queue.get(), await queue.get()]
+ assert response.answer == "hello"
+ assert chunks[0].tool_call_name == "search"
+ assert chunks[1].session_id == "session-new"
+ assert chunks[2].metadata["answer"] == "hello"
+ assert agent.reply_kwargs["resume"] == "session-old"
+ assert agent.reply_kwargs["job_tools"] == [
+ "search",
+ "list",
+ "read",
+ "read_image",
+ "frontmatter_read",
+ "stat",
+ "traverse",
+ ]
+ assert agent.reply_kwargs["builtin_tools"] == []
+ system_prompt = agent.reply_kwargs["system_prompt"]
+ assert f"Current date: {datetime.now(ZoneInfo('Asia/Shanghai')).date().isoformat()}" in system_prompt
+ assert f"Current working directory: {tmp_path.resolve()}" in system_prompt
-def test_chat_step_appends_environment_context_to_system_prompt_override(tmp_path):
+@pytest.mark.asyncio
+async def test_chat_step_appends_environment_context_to_system_prompt_override(tmp_path):
"""A caller prompt override retains request-time date and cwd context."""
- async def run():
- app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
- agent = _StreamingAgent(app_context=app_context)
+ app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
+ agent = _StreamingAgent(app_context=app_context)
- await ChatStep(agent_wrapper=agent, app_context=app_context)(
- query="hello",
- system_prompt="Custom prompt.",
- stream_queue=asyncio.Queue(),
- )
+ await ChatStep(agent_wrapper=agent, app_context=app_context)(
+ query="hello",
+ system_prompt="Custom prompt.",
+ stream_queue=asyncio.Queue(),
+ )
- system_prompt = agent.reply_kwargs["system_prompt"]
- assert system_prompt.startswith("Custom prompt.\n\n")
- assert "Current date:" in system_prompt
- assert f"Current working directory: {tmp_path.resolve()}" in system_prompt
-
- asyncio.run(run())
+ system_prompt = agent.reply_kwargs["system_prompt"]
+ assert system_prompt.startswith("Custom prompt.\n\n")
+ assert "Current date:" in system_prompt
+ assert f"Current working directory: {tmp_path.resolve()}" in system_prompt
diff --git a/tests/unit/test_write_metadata_lock.py b/tests/unit/test_write_metadata_lock.py
index 45f93d00..adb39812 100644
--- a/tests/unit/test_write_metadata_lock.py
+++ b/tests/unit/test_write_metadata_lock.py
@@ -14,12 +14,14 @@ interleaving).
# pylint: disable=protected-access
+from contextlib import chdir as temp_chdir
import asyncio
-import os
import tempfile
import warnings
from pathlib import Path
+import pytest
+
import frontmatter
from reme.components.file_store import LocalFileStore
@@ -29,26 +31,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
-class temp_chdir:
- """Context manager to temporarily chdir into a path and restore on exit."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
-def _run(coro):
- asyncio.run(coro)
-
-
async def _make_store() -> LocalFileStore:
store = LocalFileStore(name="t_write_meta", embedding_store="")
await store.start()
@@ -64,108 +46,97 @@ async def _write(store: LocalFileStore, **kwargs):
# -- metadata expansion ------------------------------------------------------
-def test_write_metadata_extends_frontmatter():
+@pytest.mark.asyncio
+async def test_write_metadata_extends_frontmatter():
"""``metadata={"tags": [...]}`` ends up as a frontmatter field on disk."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _write(
- store,
- path="note.md",
- name="Hello",
- description="A note",
- content="body text",
- metadata={"tags": ["alpha", "beta"], "priority": 3},
- )
- assert resp.success is True, resp
- on_disk = (Path(tmp) / "note.md").read_text(encoding="utf-8")
- post = frontmatter.loads(on_disk)
- assert post.metadata["name"] == "Hello"
- assert post.metadata["description"] == "A note"
- assert post.metadata["tags"] == ["alpha", "beta"]
- assert post.metadata["priority"] == 3
- assert post.content.strip() == "body text"
- await store.close()
- print("✓ test_write_metadata_extends_frontmatter passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _write(
+ store,
+ path="note.md",
+ name="Hello",
+ description="A note",
+ content="body text",
+ metadata={"tags": ["alpha", "beta"], "priority": 3},
+ )
+ assert resp.success is True, resp
+ on_disk = (Path(tmp) / "note.md").read_text(encoding="utf-8")
+ post = frontmatter.loads(on_disk)
+ assert post.metadata["name"] == "Hello"
+ assert post.metadata["description"] == "A note"
+ assert post.metadata["tags"] == ["alpha", "beta"]
+ assert post.metadata["priority"] == 3
+ assert post.content.strip() == "body text"
+ await store.close()
-def test_write_metadata_reserved_keys_ignored():
+@pytest.mark.asyncio
+async def test_write_metadata_reserved_keys_ignored():
"""``name`` / ``description`` inside ``metadata`` are dropped; explicit args win."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _write(
- store,
- path="note.md",
- name="ExplicitName",
- description="ExplicitDesc",
- content="body",
- metadata={"name": "DroppedName", "description": "DroppedDesc", "tag": "kept"},
- )
- assert resp.success is True, resp
- post = frontmatter.loads((Path(tmp) / "note.md").read_text(encoding="utf-8"))
- assert post.metadata["name"] == "ExplicitName"
- assert post.metadata["description"] == "ExplicitDesc"
- assert post.metadata["tag"] == "kept"
- await store.close()
- print("✓ test_write_metadata_reserved_keys_ignored passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _write(
+ store,
+ path="note.md",
+ name="ExplicitName",
+ description="ExplicitDesc",
+ content="body",
+ metadata={"name": "DroppedName", "description": "DroppedDesc", "tag": "kept"},
+ )
+ assert resp.success is True, resp
+ post = frontmatter.loads((Path(tmp) / "note.md").read_text(encoding="utf-8"))
+ assert post.metadata["name"] == "ExplicitName"
+ assert post.metadata["description"] == "ExplicitDesc"
+ assert post.metadata["tag"] == "kept"
+ await store.close()
-def test_write_no_metadata_preserves_legacy_shape():
+@pytest.mark.asyncio
+async def test_write_no_metadata_preserves_legacy_shape():
"""No ``metadata`` arg → frontmatter still contains only name/description."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _write(
- store,
- path="note.md",
- name="N",
- description="D",
- content="body",
- )
- assert resp.success is True, resp
- post = frontmatter.loads((Path(tmp) / "note.md").read_text(encoding="utf-8"))
- assert set(post.metadata.keys()) == {"name", "description"}
- await store.close()
- print("✓ test_write_no_metadata_preserves_legacy_shape passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _write(
+ store,
+ path="note.md",
+ name="N",
+ description="D",
+ content="body",
+ )
+ assert resp.success is True, resp
+ post = frontmatter.loads((Path(tmp) / "note.md").read_text(encoding="utf-8"))
+ assert set(post.metadata.keys()) == {"name", "description"}
+ await store.close()
-def test_write_non_md_drops_metadata():
+@pytest.mark.asyncio
+async def test_write_non_md_drops_metadata():
"""Non-markdown target: metadata silently dropped, body written verbatim."""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- resp = await _write(
- store,
- path="note.txt",
- name="N",
- description="D",
- content="plain body",
- metadata={"tag": "ignored"},
- )
- assert resp.success is True, resp
- on_disk = (Path(tmp) / "note.txt").read_text(encoding="utf-8")
- assert on_disk == "plain body"
- await store.close()
- print("✓ test_write_non_md_drops_metadata passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ resp = await _write(
+ store,
+ path="note.txt",
+ name="N",
+ description="D",
+ content="plain body",
+ metadata={"tag": "ignored"},
+ )
+ assert resp.success is True, resp
+ on_disk = (Path(tmp) / "note.txt").read_text(encoding="utf-8")
+ assert on_disk == "plain body"
+ await store.close()
# -- per-path lock -----------------------------------------------------------
-def test_write_lock_serializes_concurrent_writes():
+@pytest.mark.asyncio
+async def test_write_lock_serializes_concurrent_writes():
"""Many concurrent writes at one path land cleanly — no torn frontmatter.
Without the lock, concurrent writers can interleave reads-of-existence
@@ -174,34 +145,30 @@ def test_write_lock_serializes_concurrent_writes():
frontmatter with name matching exactly one of the writers.
"""
- async def run():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _make_store()
- n = 16
- await asyncio.gather(
- *(
- _write(
- store,
- path="shared.md",
- name=f"writer-{i}",
- description=f"desc-{i}",
- content=f"body-{i}",
- )
- for i in range(n)
- ),
- )
- on_disk = (Path(tmp) / "shared.md").read_text(encoding="utf-8")
- post = frontmatter.loads(on_disk)
- assert post.metadata.get("name", "").startswith("writer-"), post.metadata
- assert post.metadata.get("description", "").startswith("desc-"), post.metadata
- assert post.content.strip().startswith("body-"), post.content
- # The body, name, and description must all come from the SAME write
- # (no interleaving). Extract the index from each.
- idx_name = post.metadata["name"].split("-", 1)[1]
- idx_desc = post.metadata["description"].split("-", 1)[1]
- idx_body = post.content.strip().split("-", 1)[1]
- assert idx_name == idx_desc == idx_body, (idx_name, idx_desc, idx_body)
- await store.close()
- print("✓ test_write_lock_serializes_concurrent_writes passed")
-
- _run(run())
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _make_store()
+ n = 16
+ await asyncio.gather(
+ *(
+ _write(
+ store,
+ path="shared.md",
+ name=f"writer-{i}",
+ description=f"desc-{i}",
+ content=f"body-{i}",
+ )
+ for i in range(n)
+ ),
+ )
+ on_disk = (Path(tmp) / "shared.md").read_text(encoding="utf-8")
+ post = frontmatter.loads(on_disk)
+ assert post.metadata.get("name", "").startswith("writer-"), post.metadata
+ assert post.metadata.get("description", "").startswith("desc-"), post.metadata
+ assert post.content.strip().startswith("body-"), post.content
+ # The body, name, and description must all come from the SAME write
+ # (no interleaving). Extract the index from each.
+ idx_name = post.metadata["name"].split("-", 1)[1]
+ idx_desc = post.metadata["description"].split("-", 1)[1]
+ idx_body = post.content.strip().split("-", 1)[1]
+ assert idx_name == idx_desc == idx_body, (idx_name, idx_desc, idx_body)
+ await store.close()
diff --git a/tests/unit/test_zvec_file_store.py b/tests/unit/test_zvec_file_store.py
index 037486ac..e4710b5b 100644
--- a/tests/unit/test_zvec_file_store.py
+++ b/tests/unit/test_zvec_file_store.py
@@ -2,9 +2,8 @@
# pylint: disable=protected-access
-import asyncio
+from contextlib import chdir as temp_chdir
import json
-import os
import tempfile
import tomllib
import warnings
@@ -13,66 +12,11 @@ from pathlib import Path
import numpy as np
import pytest
+
from reme.components.file_store import ZvecLocalFileStore
-from reme.schema import FileChunk, FileNode
+from reme.schema import FileChunk
-
-class temp_chdir:
- """Temporarily chdir into a test workspace."""
-
- def __init__(self, path):
- self.path = path
- self.old = None
-
- def __enter__(self):
- self.old = os.getcwd()
- os.chdir(self.path)
- return self
-
- def __exit__(self, *exc):
- os.chdir(self.old)
-
-
-class FakeEmbeddingStore:
- """Small deterministic embedding provider used by file-store tests."""
-
- dimensions = 2
- max_batch_size = 10
- is_healthy = True
-
- def _embed(self, text: str) -> np.ndarray:
- if "beta" in text or "fresh" in text:
- return np.array([0.0, 1.0], dtype=np.float16)
- return np.array([1.0, 0.0], dtype=np.float16)
-
- async def health_check(self, _timeout: float = 2.0) -> bool:
- """Report the fake embedding service as healthy."""
- return True
-
- async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray:
- """Return a deterministic embedding for a single text."""
- return self._embed(input_text)
-
- async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
- """Attach deterministic embeddings to file chunks."""
- for chunk_node in nodes:
- chunk_node.embedding = self._embed(chunk_node.text)
- return nodes
-
-
-def run(coro):
- """Run an async test body."""
- return asyncio.run(coro)
-
-
-def node(path: str) -> FileNode:
- """Build a minimal file node."""
- return FileNode(path=path, st_mtime=1.0)
-
-
-def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk:
- """Build a minimal file chunk."""
- return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata)
+from .file_store_test_support import FakeEmbeddingStore, _new_zvec_store, _seed_unembedded_chunk, chunk, node
def embedded_chunk(chunk_id: str, path: str, text: str, embedding: list[float]) -> FileChunk:
@@ -104,15 +48,6 @@ def _tamper_with_collection(store: ZvecLocalFileStore, mutate) -> None:
del collection
-def _new_zvec_store(name, **kwargs):
- """Construct a zvec store with embedding disabled at bind time."""
- try:
- store = ZvecLocalFileStore(name=name, embedding_store="", **kwargs)
- except ImportError:
- pytest.skip("zvec is not installed")
- return store
-
-
async def _started_store(name, **kwargs) -> ZvecLocalFileStore:
"""Start a store with a fake embedding provider and a live collection."""
store = _new_zvec_store(name, **kwargs)
@@ -123,465 +58,403 @@ async def _started_store(name, **kwargs) -> ZvecLocalFileStore:
return store
-async def _seed_unembedded_chunk(store: ZvecLocalFileStore, chunk_id: str, path: str, text: str) -> None:
- """Attach one chunk without a vector, keeping the graph invariant intact."""
- store.file_chunks[chunk_id] = chunk(chunk_id, path, text)
- file_node = node(path)
- file_node.chunk_ids = [chunk_id]
- await store.file_graph.upsert_nodes([file_node])
-
-
# -- CRUD / search ------------------------------------------------------------
-def test_zvec_upsert_and_vector_search_ranks_by_similarity():
+@pytest.mark.asyncio
+async def test_zvec_upsert_and_vector_search_ranks_by_similarity():
"""Upserted chunks are searchable; results are ranked by cosine similarity."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_basic")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_basic")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
-
- results = await store.vector_search("alpha", 10, {})
- assert [c.id for c in results] == ["a", "b"]
- # Identical vector -> cosine distance 0 -> similarity score 1.
- assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
- assert results[0].scores["score"] == results[0].scores["vector"]
- await store.close()
-
- run(go())
+ results = await store.vector_search("alpha", 10, {})
+ assert [c.id for c in results] == ["a", "b"]
+ # Identical vector -> cosine distance 0 -> similarity score 1.
+ assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
+ assert results[0].scores["score"] == results[0].scores["vector"]
+ await store.close()
-def test_zvec_same_id_text_change_updates_vector_in_place():
+@pytest.mark.asyncio
+async def test_zvec_same_id_text_change_updates_vector_in_place():
"""A same-id text change replaces the vector via native zvec upsert."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_update")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_update")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])])
-
- assert store._indexed_ids == {"a"}
- results = await store.vector_search("beta", 5, {})
- assert [c.id for c in results] == ["a"]
- assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
- await store.close()
-
- run(go())
+ assert store._indexed_ids == {"a"}
+ results = await store.vector_search("beta", 5, {})
+ assert [c.id for c in results] == ["a"]
+ assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
+ await store.close()
-def test_zvec_explicit_embedding_change_updates_collection():
+@pytest.mark.asyncio
+async def test_zvec_explicit_embedding_change_updates_collection():
"""A same-id, same-text update with a new explicit embedding replaces the vector."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_explicit_embedding")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_explicit_embedding")
+ await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [1.0, 0.0])])])
+ await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [0.0, 1.0])])])
- await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [1.0, 0.0])])])
- await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [0.0, 1.0])])])
+ # "beta" embeds to [0, 1]; the collection must hold the new vector.
+ results = await store.vector_search("beta", 5, {})
+ assert [c.id for c in results] == ["same"]
+ assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
+ await store.close()
- # "beta" embeds to [0, 1]; the collection must hold the new vector.
- results = await store.vector_search("beta", 5, {})
- assert [c.id for c in results] == ["same"]
- assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
- await store.close()
+ # The stale vector must not survive a restart either.
+ reopened = _new_zvec_store("t_zvec_explicit_embedding")
+ reopened.embedding_store = FakeEmbeddingStore()
+ rebuilds = _track_rebuilds(reopened)
+ await reopened.start()
- # The stale vector must not survive a restart either.
- reopened = _new_zvec_store("t_zvec_explicit_embedding")
- reopened.embedding_store = FakeEmbeddingStore()
- rebuilds = _track_rebuilds(reopened)
- await reopened.start()
-
- assert not rebuilds # the collection was already in sync, no repair needed
- results = await reopened.vector_search("beta", 5, {})
- assert [c.id for c in results] == ["same"]
- assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
- await reopened.close()
-
- run(go())
+ assert not rebuilds # the collection was already in sync, no repair needed
+ results = await reopened.vector_search("beta", 5, {})
+ assert [c.id for c in results] == ["same"]
+ assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
+ await reopened.close()
-def test_zvec_upsert_removes_stale_chunks_from_collection():
+@pytest.mark.asyncio
+async def test_zvec_upsert_removes_stale_chunks_from_collection():
"""Re-upserting a path deletes vectors of chunks that no longer exist."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_stale")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_stale")
+ await store.upsert([(node("a.md"), [chunk("old", "a.md", "alpha old")])])
+ await store.upsert([(node("a.md"), [chunk("new", "a.md", "alpha new")])])
- await store.upsert([(node("a.md"), [chunk("old", "a.md", "alpha old")])])
- await store.upsert([(node("a.md"), [chunk("new", "a.md", "alpha new")])])
-
- assert store._indexed_ids == {"new"}
- assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["new"]
- await store.close()
-
- run(go())
+ assert store._indexed_ids == {"new"}
+ assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["new"]
+ await store.close()
-def test_zvec_delete_removes_vectors():
+@pytest.mark.asyncio
+async def test_zvec_delete_removes_vectors():
"""Deleting a path removes its chunk vectors from the collection."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_delete")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_delete")
+ await store.upsert(
+ [
+ (node("a.md"), [chunk("a", "a.md", "alpha text")]),
+ (node("b.md"), [chunk("b", "b.md", "beta text")]),
+ ],
+ )
+ await store.delete("a.md")
- await store.upsert(
- [
- (node("a.md"), [chunk("a", "a.md", "alpha text")]),
- (node("b.md"), [chunk("b", "b.md", "beta text")]),
- ],
- )
- await store.delete("a.md")
-
- assert store._indexed_ids == {"b"}
- assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["b"]
- await store.close()
-
- run(go())
+ assert store._indexed_ids == {"b"}
+ assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["b"]
+ await store.close()
-def test_zvec_vector_search_applies_post_filter():
+@pytest.mark.asyncio
+async def test_zvec_vector_search_applies_post_filter():
"""The shared search_filter semantics apply on top of ANN results."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_filter")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_filter")
+ await store.upsert(
+ [
+ (node("a.md"), [chunk("a", "a.md", "alpha one", kind="x")]),
+ (node("b.md"), [chunk("b", "b.md", "alpha two", kind="y")]),
+ ],
+ )
- await store.upsert(
- [
- (node("a.md"), [chunk("a", "a.md", "alpha one", kind="x")]),
- (node("b.md"), [chunk("b", "b.md", "alpha two", kind="y")]),
- ],
- )
-
- assert [c.id for c in await store.vector_search("alpha", 10, {"path": "b.md"})] == ["b"]
- assert [c.id for c in await store.vector_search("alpha", 10, {"kind": "x"})] == ["a"]
- assert await store.vector_search("alpha", 10, {"path": "c.md"}) == []
- await store.close()
-
- run(go())
+ assert [c.id for c in await store.vector_search("alpha", 10, {"path": "b.md"})] == ["b"]
+ assert [c.id for c in await store.vector_search("alpha", 10, {"kind": "x"})] == ["a"]
+ assert await store.vector_search("alpha", 10, {"path": "c.md"}) == []
+ await store.close()
-def test_zvec_vector_search_uses_current_query_api():
+@pytest.mark.asyncio
+async def test_zvec_vector_search_uses_current_query_api():
"""The ANN query path must not rely on zvec's deprecated VectorQuery alias."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_query_api")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_query_api")
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
-
- with warnings.catch_warnings():
- warnings.simplefilter("error", DeprecationWarning)
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DeprecationWarning)
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_clear_resets_collection():
+@pytest.mark.asyncio
+async def test_zvec_clear_resets_collection():
"""clear() drops the collection, sidecar, and indexed-id state."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_clear")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_clear")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.dump()
+ assert store.zvec_sidecar_path.exists()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.dump()
- assert store.zvec_sidecar_path.exists()
+ await store.clear()
- await store.clear()
-
- assert store._indexed_ids == set()
- assert not store.zvec_sidecar_path.exists()
- assert await store.vector_search("alpha", 10, {}) == []
- # The store stays usable after clear.
- await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
- assert [c.id for c in await store.vector_search("beta", 10, {})] == ["b"]
- await store.close()
-
- run(go())
+ assert store._indexed_ids == set()
+ assert not store.zvec_sidecar_path.exists()
+ assert await store.vector_search("alpha", 10, {}) == []
+ # The store stays usable after clear.
+ await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
+ assert [c.id for c in await store.vector_search("beta", 10, {})] == ["b"]
+ await store.close()
-def test_zvec_keyword_only_mode_keeps_keyword_search_working():
+@pytest.mark.asyncio
+async def test_zvec_keyword_only_mode_keeps_keyword_search_working():
"""Without an embedding store, vector search is empty but keyword search works."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = _new_zvec_store("t_zvec_keyword_only")
+ await store.start()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = _new_zvec_store("t_zvec_keyword_only")
- await store.start()
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "uniquezvecword only")])])
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "uniquezvecword only")])])
-
- assert store._collection is None
- assert await store.vector_search("uniquezvecword", 5, {}) == []
- assert [c.id for c in await store.keyword_search("uniquezvecword", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert store._collection is None
+ assert await store.vector_search("uniquezvecword", 5, {}) == []
+ assert [c.id for c in await store.keyword_search("uniquezvecword", 5, {})] == ["a"]
+ await store.close()
# -- embedding backfill ---------------------------------------------------------
-def test_zvec_backfill_adds_incrementally():
+@pytest.mark.asyncio
+async def test_zvec_backfill_adds_incrementally():
"""Backfilled vectors are upserted into the live collection without a rebuild."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_backfill")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_backfill")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
+ rebuilds = []
+ original_rebuild = store._rebuild_collection
+ store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
- rebuilds = []
- original_rebuild = store._rebuild_collection
- store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
+ await store._backfill_missing_embeddings()
- await store._backfill_missing_embeddings()
-
- assert not rebuilds
- assert store._indexed_ids == {"a", "c"}
- assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "c"}
- await store.close()
-
- run(go())
+ assert not rebuilds
+ assert store._indexed_ids == {"a", "c"}
+ assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "c"}
+ await store.close()
# -- persistence ----------------------------------------------------------------
-def test_zvec_persistence_round_trip_reopens_without_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_persistence_round_trip_reopens_without_rebuild():
"""dump() + a fresh store reattaches the persisted collection via the sidecar."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_persist")
+ await seed.upsert(
+ [
+ (node("a.md"), [chunk("a", "a.md", "alpha text")]),
+ (node("b.md"), [chunk("b", "b.md", "beta text")]),
+ ],
+ )
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_persist")
- await seed.upsert(
- [
- (node("a.md"), [chunk("a", "a.md", "alpha text")]),
- (node("b.md"), [chunk("b", "b.md", "beta text")]),
- ],
- )
- await seed.close()
+ store = _new_zvec_store("t_zvec_persist")
+ store.embedding_store = FakeEmbeddingStore()
- store = _new_zvec_store("t_zvec_persist")
- store.embedding_store = FakeEmbeddingStore()
+ rebuilds = []
+ original_rebuild = store._rebuild_collection
+ store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
- rebuilds = []
- original_rebuild = store._rebuild_collection
- store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
+ await store.start()
- await store.start()
-
- assert not rebuilds
- assert store._indexed_ids == {"a", "b"}
- assert [c.id for c in await store.vector_search("alpha", 5, {})][0] == "a"
- assert [c.id for c in await store.vector_search("beta", 5, {})][0] == "b"
- await store.close()
-
- run(go())
+ assert not rebuilds
+ assert store._indexed_ids == {"a", "b"}
+ assert [c.id for c in await store.vector_search("alpha", 5, {})][0] == "a"
+ assert [c.id for c in await store.vector_search("beta", 5, {})][0] == "b"
+ await store.close()
-def test_zvec_digest_mismatch_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_digest_mismatch_triggers_rebuild():
"""A sidecar whose digest fell behind the chunk JSONL forces a clean rebuild."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_digest")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_digest")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ sidecar = json.loads(seed.zvec_sidecar_path.read_text())
+ sidecar["digest"] = "0" * 64
+ seed.zvec_sidecar_path.write_text(json.dumps(sidecar))
- sidecar = json.loads(seed.zvec_sidecar_path.read_text())
- sidecar["digest"] = "0" * 64
- seed.zvec_sidecar_path.write_text(json.dumps(sidecar))
+ store = _new_zvec_store("t_zvec_digest")
+ store.embedding_store = FakeEmbeddingStore()
- store = _new_zvec_store("t_zvec_digest")
- store.embedding_store = FakeEmbeddingStore()
+ rebuilds = []
+ original_rebuild = store._rebuild_collection
+ store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
- rebuilds = []
- original_rebuild = store._rebuild_collection
- store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
+ await store.start()
- await store.start()
-
- assert rebuilds == [True]
- assert store._indexed_ids == {"a"}
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert rebuilds == [True]
+ assert store._indexed_ids == {"a"}
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_missing_sidecar_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_missing_sidecar_triggers_rebuild():
"""A collection directory without its sidecar cannot be trusted and is rebuilt."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_no_sidecar")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_no_sidecar")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ seed.zvec_sidecar_path.unlink()
- seed.zvec_sidecar_path.unlink()
+ store = _new_zvec_store("t_zvec_no_sidecar")
+ store.embedding_store = FakeEmbeddingStore()
+ await store.start()
- store = _new_zvec_store("t_zvec_no_sidecar")
- store.embedding_store = FakeEmbeddingStore()
- await store.start()
-
- assert store._indexed_ids == {"a"}
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert store._indexed_ids == {"a"}
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_hnsw_m_mismatch_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_hnsw_m_mismatch_triggers_rebuild():
"""A persisted collection built with a different HNSW M is rebuilt."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_m_mismatch", hnsw_m=16)
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_m_mismatch", hnsw_m=16)
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ store = _new_zvec_store("t_zvec_m_mismatch", hnsw_m=48)
+ store.embedding_store = FakeEmbeddingStore()
- store = _new_zvec_store("t_zvec_m_mismatch", hnsw_m=48)
- store.embedding_store = FakeEmbeddingStore()
+ rebuilds = []
+ original_rebuild = store._rebuild_collection
+ store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
- rebuilds = []
- original_rebuild = store._rebuild_collection
- store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
+ await store.start()
- await store.start()
-
- assert rebuilds == [True]
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert rebuilds == [True]
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_missing_collection_document_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_missing_collection_document_triggers_rebuild():
"""A collection that lost a document is rebuilt even though the sidecar matches."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_missing_doc")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_missing_doc")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ _tamper_with_collection(seed, lambda _zvec, collection: collection.delete(ids=["a"]))
+ # The sidecar still claims the chunk is indexed.
+ assert json.loads(seed.zvec_sidecar_path.read_text())["ids"] == ["a"]
- _tamper_with_collection(seed, lambda _zvec, collection: collection.delete(ids=["a"]))
- # The sidecar still claims the chunk is indexed.
- assert json.loads(seed.zvec_sidecar_path.read_text())["ids"] == ["a"]
+ store = _new_zvec_store("t_zvec_missing_doc")
+ store.embedding_store = FakeEmbeddingStore()
+ rebuilds = _track_rebuilds(store)
+ await store.start()
- store = _new_zvec_store("t_zvec_missing_doc")
- store.embedding_store = FakeEmbeddingStore()
- rebuilds = _track_rebuilds(store)
- await store.start()
-
- assert rebuilds == [True]
- assert store._indexed_ids == {"a"}
- assert store._collection.stats.doc_count == 1
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert rebuilds == [True]
+ assert store._indexed_ids == {"a"}
+ assert store._collection.stats.doc_count == 1
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_unexpected_collection_document_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_unexpected_collection_document_triggers_rebuild():
"""A collection holding a document no chunk owns is rebuilt."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_extra_doc")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_extra_doc")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ _tamper_with_collection(
+ seed,
+ lambda zvec, collection: collection.upsert(
+ [zvec.Doc(id="ghost", vectors={"embedding": [0.0, 1.0]})],
+ ),
+ )
- _tamper_with_collection(
- seed,
- lambda zvec, collection: collection.upsert(
- [zvec.Doc(id="ghost", vectors={"embedding": [0.0, 1.0]})],
- ),
- )
+ store = _new_zvec_store("t_zvec_extra_doc")
+ store.embedding_store = FakeEmbeddingStore()
+ rebuilds = _track_rebuilds(store)
+ await store.start()
- store = _new_zvec_store("t_zvec_extra_doc")
- store.embedding_store = FakeEmbeddingStore()
- rebuilds = _track_rebuilds(store)
- await store.start()
-
- assert rebuilds == [True]
- assert store._indexed_ids == {"a"}
- assert store._collection.stats.doc_count == 1
- assert [c.id for c in await store.vector_search("beta", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert rebuilds == [True]
+ assert store._indexed_ids == {"a"}
+ assert store._collection.stats.doc_count == 1
+ assert [c.id for c in await store.vector_search("beta", 5, {})] == ["a"]
+ await store.close()
-def test_zvec_corrupted_collection_vector_triggers_rebuild():
+@pytest.mark.asyncio
+async def test_zvec_corrupted_collection_vector_triggers_rebuild():
"""An expected id whose stored vector was replaced is rebuilt from the chunks."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_bad_vector")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_bad_vector")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ _tamper_with_collection(
+ seed,
+ lambda zvec, collection: collection.upsert(
+ [zvec.Doc(id="a", vectors={"embedding": [0.0, 1.0]})],
+ ),
+ )
- _tamper_with_collection(
- seed,
- lambda zvec, collection: collection.upsert(
- [zvec.Doc(id="a", vectors={"embedding": [0.0, 1.0]})],
- ),
- )
+ store = _new_zvec_store("t_zvec_bad_vector")
+ store.embedding_store = FakeEmbeddingStore()
+ rebuilds = _track_rebuilds(store)
+ await store.start()
- store = _new_zvec_store("t_zvec_bad_vector")
- store.embedding_store = FakeEmbeddingStore()
- rebuilds = _track_rebuilds(store)
- await store.start()
-
- assert rebuilds == [True]
- # The rebuilt collection carries the vector of the authoritative chunk.
- results = await store.vector_search("alpha", 5, {})
- assert [c.id for c in results] == ["a"]
- assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
- await store.close()
-
- run(go())
+ assert rebuilds == [True]
+ # The rebuilt collection carries the vector of the authoritative chunk.
+ results = await store.vector_search("alpha", 5, {})
+ assert [c.id for c in results] == ["a"]
+ assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
+ await store.close()
-def test_zvec_stale_embedding_dimension_rebuilds_from_backfill():
+@pytest.mark.asyncio
+async def test_zvec_stale_embedding_dimension_rebuilds_from_backfill():
"""Persisted vectors with a stale dimension are dropped and re-embedded."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ seed = await _started_store("t_zvec_stale_dim")
+ await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await seed.close()
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- seed = await _started_store("t_zvec_stale_dim")
- await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await seed.close()
+ class WideEmbeddingStore(FakeEmbeddingStore):
+ """Same provider with a different dimension."""
- class WideEmbeddingStore(FakeEmbeddingStore):
- """Same provider with a different dimension."""
+ dimensions = 4
- dimensions = 4
+ def _embed(self, text: str) -> np.ndarray:
+ base = super()._embed(text)
+ return np.concatenate([base, base]).astype(np.float16)
- def _embed(self, text: str) -> np.ndarray:
- base = super()._embed(text)
- return np.concatenate([base, base]).astype(np.float16)
+ store = _new_zvec_store("t_zvec_stale_dim")
+ store.embedding_store = WideEmbeddingStore()
+ await store.start()
+ # Startup backfill re-embeds the stale chunk in the background.
+ await store._embedding_backfill_task
- store = _new_zvec_store("t_zvec_stale_dim")
- store.embedding_store = WideEmbeddingStore()
- await store.start()
- # Startup backfill re-embeds the stale chunk in the background.
- await store._embedding_backfill_task
-
- assert store._collection.schema.vectors[0].dimension == 4
- assert store._indexed_ids == {"a"}
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert store._collection.schema.vectors[0].dimension == 4
+ assert store._indexed_ids == {"a"}
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()
# -- packaging ------------------------------------------------------------
@@ -599,17 +472,14 @@ def test_zvec_declared_as_installable_dependency():
# -- maintenance ------------------------------------------------------------
-def test_zvec_optimize_index_runs_native_optimize():
+@pytest.mark.asyncio
+async def test_zvec_optimize_index_runs_native_optimize():
"""optimize_index() delegates to Collection.optimize() and keeps search intact."""
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ store = await _started_store("t_zvec_optimize")
- async def go():
- with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
- store = await _started_store("t_zvec_optimize")
+ await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
+ await store.optimize_index()
- await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
- await store.optimize_index()
-
- assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
- await store.close()
-
- run(go())
+ assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
+ await store.close()