From c459223fbc662996402c41af8116de82fcd0435e Mon Sep 17 00:00:00 2001 From: wang-qisen Date: Tue, 1 Sep 2026 18:39:50 +0800 Subject: [PATCH] fix: address auto resource review concerns --- docs/en/auto_resource.md | 6 +- docs/zh/auto_resource.md | 4 +- reme/config/default.yaml | 24 - reme/steps/evolve/auto_image_resource.py | 165 +- reme/steps/evolve/auto_text_resource.py | 87 +- reme/steps/evolve/base_auto_resource.py | 231 ++- reme/steps/index/_watch_rules.py | 3 +- tests/unit/auto_resource_test_support.py | 269 +++ tests/unit/conftest.py | 5 + tests/unit/test_auto_image_steps.py | 1473 ++++++----------- .../test_auto_resource_review_regressions.py | 168 ++ tests/unit/test_background_steps.py | 315 +++- 12 files changed, 1479 insertions(+), 1271 deletions(-) create mode 100644 tests/unit/auto_resource_test_support.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_auto_resource_review_regressions.py diff --git a/docs/en/auto_resource.md b/docs/en/auto_resource.md index 503b3590..002445fc 100644 --- a/docs/en/auto_resource.md +++ b/docs/en/auto_resource.md @@ -91,9 +91,9 @@ The resource card links to the original file through frontmatter: source_resource: "[[resource/2026-06-20/market-report.md]]" ``` -When a resource changes, Auto Resource finds and updates the corresponding card through `source_resource`. When a -resource is deleted, its daily note is also removed. The older `daily/YYYY-MM-DD/.md` naming convention -remains supported as a fallback. +When a resource changes, Auto Resource finds and updates the corresponding card through an exact `source_resource` +match. When a resource is deleted, only the explicitly linked daily note is removed. A same-stem note without that +provenance marker is treated as user-owned and left untouched; new resource cards use a collision-free path instead. ## Daily Index diff --git a/docs/zh/auto_resource.md b/docs/zh/auto_resource.md index 00201a0b..47e23432 100644 --- a/docs/zh/auto_resource.md +++ b/docs/zh/auto_resource.md @@ -78,8 +78,8 @@ daily/2026-06-20/市场报告要点.md source_resource: "[[resource/2026-06-20/market-report.md]]" ``` -如果资源文件更新,Auto Resource 会通过 `source_resource` 找到对应卡片并更新;如果资源文件删除,对应的 daily note 也会被清理。旧版本按 -stem 生成的 `daily/YYYY-MM-DD/.md` 仍作为 fallback 兼容。 +如果资源文件更新,Auto Resource 只会通过精确匹配的 `source_resource` 找到对应卡片并更新;如果资源文件删除,也只会清理显式关联的 +daily note。缺少该来源标记的同 stem 笔记会被视为用户笔记并保留,新资源卡片则会使用无冲突路径。 ## 当天索引 diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 0f741c50..2565ae79 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -197,30 +197,6 @@ jobs: - auto_image_resource_step - auto_text_resource_step - auto_image: - backend: base - description: "Auto-image: interpret image resource files into daily caption notes" - parameters: - type: object - properties: - changes: - type: array - description: "image resource change batch, each item has path/file_path and change" - items: - type: object - properties: - path: - type: string - file_path: - type: string - change: - type: string - description: "added/modified/deleted" - required: - - changes - steps: - - backend: auto_image_resource_step - proactive: backend: base description: "Proactive: read daily//interests.yaml and expose the latest user-interest topics." diff --git a/reme/steps/evolve/auto_image_resource.py b/reme/steps/evolve/auto_image_resource.py index 5f15fe1b..8e4593a4 100644 --- a/reme/steps/evolve/auto_image_resource.py +++ b/reme/steps/evolve/auto_image_resource.py @@ -162,6 +162,7 @@ def _parse_caption_json(text: str) -> dict: fence = _JSON_FENCE_RE.match(cleaned) if fence: cleaned = fence.group(1) + parsed_json = False for candidate in (cleaned, cleaned[cleaned.find("{") : cleaned.rfind("}") + 1]): if not candidate: continue @@ -169,11 +170,14 @@ def _parse_caption_json(text: str) -> dict: parsed = json.loads(candidate) except (json.JSONDecodeError, ValueError): continue + parsed_json = True if isinstance(parsed, dict): normalized = _normalize_caption_fields(parsed) if normalized["caption"] or normalized["description"]: return normalized - return {"name": "", "description": "", "caption": text.strip()} + if parsed_json: + return {"name": "", "description": "", "caption": ""} + return {"name": "", "description": "", "caption": cleaned.strip()} @R.register("auto_image_resource_step") @@ -234,18 +238,20 @@ class AutoImageResourceStep(BaseAutoResourceStep): except Exception as exc: # pylint: disable=broad-except self.logger.warning(f"[{self.name}] structured caption failed ({exc}); retrying with a plain call") result = await model([user_message]) - return _parse_caption_json(await _response_text(result)) + parsed = _parse_caption_json(await _response_text(result)) + if not parsed["caption"] and not parsed["description"]: + raise RuntimeError("Vision model returned no usable caption") + return parsed - async def _read_image(self, file_path: str) -> dict | None: + async def _read_image(self, file_path: str, source_path: Path) -> dict | None: """Read the image file and build the VLM request payload. Returns ``None`` when the change must be skipped (stat failure or oversized file); the skip outcome is already recorded on the response. """ - abs_path = self.workspace_path / file_path max_image_bytes = self._max_image_bytes() try: - size_bytes = abs_path.stat().st_size + size_bytes = source_path.stat().st_size except OSError as exc: self.context.response.success = False self.context.response.answer = f"Failed to inspect resource file: {file_path}: {exc}" @@ -282,7 +288,7 @@ class AutoImageResourceStep(BaseAutoResourceStep): return None self.logger.info(f"[{self.name}] read image start file_path={file_path}") - async with aiofiles.open(abs_path, "rb") as f: + async with aiofiles.open(source_path, "rb") as f: data = await f.read() payload = _build_image_request_payload(data, Path(file_path).suffix.lower()) self.logger.info( @@ -291,68 +297,17 @@ class AutoImageResourceStep(BaseAutoResourceStep): ) return payload - async def _handle_change(self, file_path: str, raw_change) -> dict: - """Skip non-image changes; isolate per-change failures so the batch continues.""" - if file_path and not self.matches_change({"path": file_path}): - file_path = self.to_workspace_relative(file_path) if Path(file_path).is_absolute() else file_path - self.context.response.metadata = {} - answer = f"Skipped non-image resource file: {file_path}" - self.context.response.success = True - self.context.response.answer = answer - self.context.response.metadata.update( - { - "path": file_path, - "action": "skipped", - "reason": "non_image_file", - "modified": False, - }, - ) - self.logger.info(f"[{self.name}] skip change file_path={file_path} reason=non_image_file") - return { - "success": True, - "path": file_path, - "change": str(raw_change), - "answer": answer, - "metadata": dict(self.context.response.metadata), - } - try: - return await super()._handle_change(file_path, raw_change) - except Exception as exc: # pylint: disable=broad-except - self.context.response.success = False - self.context.response.answer = f"Failed to caption image resource: {file_path}: {exc}" - self.context.response.metadata.setdefault("path", file_path) - self.context.response.metadata.setdefault("modified", False) - self.context.response.metadata.update( - { - "action": "failed", - "error": str(exc), - }, - ) - self.logger.warning(f"[{self.name}] caption failed file_path={file_path} error={exc}") - return { - "success": False, - "path": file_path, - "change": str(raw_change), - "answer": self.context.response.answer, - "metadata": dict(self.context.response.metadata), - } - - async def _handle_upsert(self, file_path: str, date_str: str, note_stem: str, added: bool) -> None: + async def _handle_upsert( + self, + file_path: str, + date_str: str, + note_stem: str, + added: bool, + source_path: Path, + ) -> None: """Caption the image and write/refresh its note (image counterpart of the text upsert).""" - daily_dir = self.config_value("daily_dir") - fallback_path = f"{daily_dir}/{date_str}/{note_stem}.md" - try: - note = await self._list_resource_note(date_str, file_path, fallback_path) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.logger.info(f"[{self.name}] list failed file_path={file_path} answer={str(exc)!r}") - return - - note_path = str(note["path"]) if note else fallback_path - note_created = note is None - before_note_path = note_path - before_note_bytes = self._note_bytes(note_path) + note_state = await self._prepare_resource_note(date_str, file_path, note_stem) + note_path = note_state.path self.logger.info( f"[{self.name}] upsert start file_path={file_path} date={date_str} " f"note_stem={note_stem} added={added}", ) @@ -372,7 +327,7 @@ class AutoImageResourceStep(BaseAutoResourceStep): self.logger.warning(f"[{self.name}] no vision model configured file_path={file_path}") return - payload = await self._read_image(file_path) + payload = await self._read_image(file_path, source_path) if payload is None: return @@ -419,77 +374,21 @@ class AutoImageResourceStep(BaseAutoResourceStep): ) if not write_response.success: raise RuntimeError(f"write failed: {write_response.answer}") - self.context.response.metadata.update( - { - "path": note_path, - "created": note_created, - "modified": self._note_modified(before_note_path, before_note_bytes, note_path), - }, + note_path = await self._finalize_resource_note( + note_state, + date_str, + file_path, + note_stem, + added, ) - - if note_created: - try: - note = await self._list_resource_note(date_str, file_path, fallback_path) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.context.response.metadata.update( - { - "path": note_path, - "created": note_created, - "modified": self._note_modified(before_note_path, before_note_bytes, note_path), - }, - ) - self.logger.info(f"[{self.name}] post-create list failed file_path={file_path} answer={str(exc)!r}") - return - if note is None: - self.context.response.success = True - self.context.response.answer = f"Captioned image resource {file_path}" - modified = self._note_modified(before_note_path, before_note_bytes, note_path) - self.context.response.metadata.update({"path": note_path, "created": False, "modified": modified}) - self.logger.info(f"[{self.name}] done without cataloged note file_path={file_path} modified={modified}") - return - note_path = str(note["path"]) - - try: - await self._ensure_resource_frontmatter(note_path, file_path) - note_path = await self._rename_from_frontmatter_name( - note_path, - date_str, - file_path, - note_stem, - fallback_path, - allow_rename=note_created, - ) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.context.response.metadata.update( - { - "path": note_path, - "created": note_created, - "modified": self._note_modified(before_note_path, before_note_bytes, note_path), - }, - ) - self.logger.info(f"[{self.name}] post-write failed path={note_path} answer={str(exc)!r}") - return - - modified = self._note_modified(before_note_path, before_note_bytes, note_path) - self.context.response.metadata.update({"path": note_path, "created": note_created, "modified": modified}) - index_payload = await self._refresh_day_index(date_str) + if note_path is None: + raise RuntimeError(f"Image caption note was not written: {file_path}") self.context.response.success = True self.context.response.answer = f"Captioned image resource {file_path} -> {note_path}" self.context.response.metadata.update( { - "path": note_path, - "created": note_created, - "modified": modified, - "session_id": note_stem, - "source_resource": self._source_resource_link(file_path), - "action": "added" if added else "modified", "media_type": payload["source_mime"], - "index": index_payload, }, ) - self.logger.info(f"[{self.name}] done {note_path} modified={modified}") + self.logger.info(f"[{self.name}] done {note_path} modified={self.context.response.metadata['modified']}") diff --git a/reme/steps/evolve/auto_text_resource.py b/reme/steps/evolve/auto_text_resource.py index 2d97e1c5..c8d59a27 100644 --- a/reme/steps/evolve/auto_text_resource.py +++ b/reme/steps/evolve/auto_text_resource.py @@ -1,6 +1,7 @@ """Text resource processor for the unified auto-resource router.""" import uuid +from pathlib import Path import aiofiles @@ -36,29 +37,18 @@ class AutoTextResourceStep(BaseAutoResourceStep): date_str: str, note_stem: str, added: bool, + source_path: Path, ) -> None: self.logger.info( f"[{self.name}] upsert start file_path={file_path} date={date_str} " f"note_stem={note_stem} added={added}", ) - daily_dir = self.config_value("daily_dir") - fallback_path = f"{daily_dir}/{date_str}/{note_stem}.md" - try: - note = await self._list_resource_note(date_str, file_path, fallback_path) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.logger.info(f"[{self.name}] list failed file_path={file_path} answer={str(exc)!r}") - return - - note_path = str(note["path"]) if note else fallback_path - note_created = note is None - before_note_path = note_path - before_note_bytes = self._note_bytes(note_path) + note_state = await self._prepare_resource_note(date_str, file_path, note_stem) + note_path = note_state.path + note_created = note_state.created self.logger.info(f"[{self.name}] daily note lookup path={note_path} created={note_created}") # Read resource file content - abs_path = self.workspace_path / file_path - if not abs_path.is_file(): + if not source_path.is_file(): self.context.response.success = False self.context.response.answer = f"Resource file not found: {file_path}" self.logger.warning(f"[{self.name}] resource missing file_path={file_path}") @@ -66,7 +56,7 @@ class AutoTextResourceStep(BaseAutoResourceStep): skip_read = False try: - size_bytes = abs_path.stat().st_size + size_bytes = source_path.stat().st_size except OSError as exc: self.context.response.success = False self.context.response.answer = f"Failed to inspect resource file: {file_path}: {exc}" @@ -107,7 +97,7 @@ class AutoTextResourceStep(BaseAutoResourceStep): return self.logger.info(f"[{self.name}] read resource start file_path={file_path}") - async with aiofiles.open(abs_path, encoding="utf-8", errors="replace") as f: + async with aiofiles.open(source_path, encoding="utf-8", errors="replace") as f: file_content = await f.read() self.logger.info(f"[{self.name}] read resource done file_path={file_path} chars={len(file_content)}") @@ -136,61 +126,24 @@ class AutoTextResourceStep(BaseAutoResourceStep): ) self.logger.info(f"[{self.name}] agent done file_path={file_path} has_result={bool(result.get('result'))}") - if note_created: - try: - note = await self._list_resource_note(date_str, file_path, fallback_path) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.context.response.metadata.update({"path": None, "created": note_created, "modified": False}) - self.logger.info(f"[{self.name}] post-create list failed file_path={file_path} answer={str(exc)!r}") - return - if note is None: - self.context.response.success = True - self.context.response.answer = agent_reply_result_text(result) - self.context.response.metadata.update({"path": None, "created": False, "modified": False}) - self.logger.info(f"[{self.name}] done without note file_path={file_path} modified=False") - return - note_path = str(note["path"]) - - try: - await self._ensure_resource_frontmatter(note_path, file_path) - note_path = await self._rename_from_frontmatter_name( - note_path, - date_str, - file_path, - note_stem, - fallback_path, - allow_rename=note_created, - ) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.context.response.metadata.update( - { - "path": note_path, - "created": note_created, - "modified": self._note_modified(before_note_path, before_note_bytes, note_path), - }, - ) - self.logger.info(f"[{self.name}] post-agent failed path={note_path} answer={str(exc)!r}") + note_path = await self._finalize_resource_note( + note_state, + date_str, + file_path, + note_stem, + added, + ) + if note_path is None: + self.context.response.success = True + self.context.response.answer = agent_reply_result_text(result) + self.logger.info(f"[{self.name}] done without note file_path={file_path} modified=False") return - modified = self._note_modified(before_note_path, before_note_bytes, note_path) - index_payload = await self._refresh_day_index(date_str) - self.context.response.success = True self.context.response.answer = agent_reply_result_text(result) self.context.response.metadata.update( { - "path": note_path, - "created": note_created, - "modified": modified, - "session_id": note_stem, - "source_resource": self._source_resource_link(file_path), "agent_session_id": agent_session_id, - "action": "added" if added else "modified", - "index": index_payload, }, ) - self.logger.info(f"[{self.name}] done {note_path} modified={modified}") + self.logger.info(f"[{self.name}] done {note_path} modified={self.context.response.metadata['modified']}") diff --git a/reme/steps/evolve/base_auto_resource.py b/reme/steps/evolve/base_auto_resource.py index 83623cba..3ab8c66b 100644 --- a/reme/steps/evolve/base_auto_resource.py +++ b/reme/steps/evolve/base_auto_resource.py @@ -4,6 +4,7 @@ import hashlib import re from abc import abstractmethod from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any @@ -12,6 +13,7 @@ from watchfiles import Change from ..base_step import BaseStep from ..file_io import refresh_day_index, validate_filename_component +from ..file_io._path import is_relative_to, resolve_path from ._evolve import now _SOURCE_RESOURCE_KEY = "source_resource" @@ -19,6 +21,15 @@ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") _UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]+') +@dataclass(frozen=True) +class _ResourceNoteState: + """Snapshot a linked note, or a collision-free path reserved for a new one.""" + + path: str + created: bool + before_bytes: bytes | None + + def _compute_note_stem(filename: str) -> str: """Return the daily note stem for a resource filename.""" return PurePosixPath(filename).stem @@ -118,6 +129,68 @@ class BaseAutoResourceStep(BaseStep): def _daily_note_path(self, day: str, name: str) -> str: return f"{self.config_value('daily_dir')}/{day}/{name}.md" + def _resource_directory(self) -> tuple[str, Path]: + """Return the workspace-relative identity and resolved resource root.""" + workspace = self.workspace_path.resolve() + configured = str(self.config_value("resource_dir")) + resolved, error = resolve_path(workspace, configured) + if error or resolved is None: + raise ValueError(f"invalid resource_dir {configured!r}: {error or 'cannot resolve path'}") + + logical = Path(configured) + if logical.is_absolute(): + for workspace_variant in (self.workspace_path.absolute(), workspace): + try: + logical = logical.relative_to(workspace_variant) + break + except ValueError: + continue + else: + raise ValueError("resource_dir must stay inside the workspace") + return logical.as_posix(), resolved + + def _resolve_resource_source(self, raw_path: str) -> tuple[str, Path, str]: + """Return a logical resource path, safe read path, and logical resource root. + + The logical path is kept for ``source_resource`` provenance. The + resolved path is used for stat/read so a symlink cannot escape the + configured resource directory. + """ + value = str(raw_path or "").strip() + if not value: + raise ValueError("resource path is required") + + supplied = Path(value) + if any(part in {".", ".."} for part in supplied.parts): + raise ValueError(f"resource path cannot contain '.' or '..': {value!r}") + + workspace = self.workspace_path.resolve() + resource_dir, resource_root = self._resource_directory() + + resolved, path_error = resolve_path(workspace, value) + if path_error or resolved is None: + raise ValueError(f"invalid resource path {value!r}: {path_error or 'cannot resolve path'}") + if not is_relative_to(resolved, resource_root): + raise ValueError("resource path must stay inside the configured resource directory") + + if supplied.is_absolute(): + try: + logical = supplied.relative_to(self.workspace_path.absolute()) + except ValueError: + try: + logical = supplied.relative_to(workspace) + except ValueError as exc: + raise ValueError("resource path must stay inside the workspace") from exc + else: + logical = supplied + + resource_logical = Path(resource_dir) + try: + logical.relative_to(resource_logical) + except ValueError as exc: + raise ValueError("resource path must stay inside the configured resource directory") from exc + return logical.as_posix(), resolved, resource_dir + @staticmethod def _source_resource_link(file_path: str) -> str: return f"[[{file_path}]]" @@ -148,22 +221,57 @@ class BaseAutoResourceStep(BaseStep): self.logger.info(f"[{self.name}] refresh index done date={day}") return index_payload - def _find_resource_note(self, notes: list[dict], file_path: str, fallback_path: str) -> dict | None: + def _find_resource_note(self, notes: list[dict], file_path: str) -> dict | None: + """Return only a note explicitly owned by ``file_path``.""" source = self._source_resource_link(file_path) for note in notes: if str(note.get(_SOURCE_RESOURCE_KEY, "")).strip() == source: return note - for note in notes: - if str(note.get("path", "")).strip() == fallback_path: - return note return None - async def _list_resource_note(self, day: str, file_path: str, fallback_path: str) -> dict | None: + async def _list_resource_note(self, day: str, file_path: str) -> dict | None: list_response = await self.run_job("daily_list", date=day) if not list_response.success: raise RuntimeError(f"daily_list failed: {list_response.answer}") notes = list_response.metadata.get("notes") or [] - return self._find_resource_note(notes, file_path, fallback_path) + return self._find_resource_note(notes, file_path) + + async def _prepare_resource_note(self, day: str, file_path: str, note_stem: str) -> _ResourceNoteState: + """Find the owned note or allocate a safe path before the first write.""" + note = await self._list_resource_note(day, file_path) + if note is not None: + note_path = str(note["path"]) + return _ResourceNoteState(path=note_path, created=False, before_bytes=self._note_bytes(note_path)) + + _, note_path = self._unique_daily_note_path(day, note_stem, file_path, current_path="") + return _ResourceNoteState(path=note_path, created=True, before_bytes=None) + + async def _resolve_written_note( + self, + state: _ResourceNoteState, + day: str, + file_path: str, + ) -> str | None: + """Resolve a processor write without claiming a pre-existing same-stem note.""" + if not state.created: + if self._note_bytes(state.path) is None: + raise RuntimeError(f"linked resource note disappeared: {state.path}") + return state.path + + note = await self._list_resource_note(day, file_path) + if note is not None: + return str(note["path"]) + if self._note_bytes(state.path) is None: + return None + + # The path was absent when this invocation allocated it, so a note + # created there without provenance can be repaired conservatively. A + # different explicit owner is always a conflict and is never claimed. + source = str(self._frontmatter(state.path).get(_SOURCE_RESOURCE_KEY, "")).strip() + expected = self._source_resource_link(file_path) + if source and source != expected: + raise RuntimeError(f"resource note path is owned by another source: {state.path}") + return state.path async def _ensure_resource_frontmatter(self, path: str, file_path: str) -> None: metadata = {_SOURCE_RESOURCE_KEY: self._source_resource_link(file_path)} @@ -212,7 +320,6 @@ class BaseAutoResourceStep(BaseStep): day: str, file_path: str, fallback_name: str, - fallback_path: str, *, allow_rename: bool, ) -> str: @@ -220,7 +327,7 @@ class BaseAutoResourceStep(BaseStep): current_name = PurePosixPath(path).stem suggested_name = str(meta.get("name", "")).strip() - if not allow_rename and path != fallback_path: + if not allow_rename: name = _sanitize_note_name(current_name, fallback_name) if suggested_name != name: await self._set_frontmatter_name(path, name) @@ -245,18 +352,73 @@ class BaseAutoResourceStep(BaseStep): raise RuntimeError(f"move failed: {move_response.answer}") return target_path + async def _finalize_resource_note( + self, + state: _ResourceNoteState, + day: str, + file_path: str, + note_stem: str, + added: bool, + ) -> str | None: + """Resolve, source-link, rename, index, and report one processor write.""" + staged_bytes = self._note_bytes(state.path) + self.context.response.metadata.update( + { + "path": state.path if staged_bytes is not None else None, + "created": state.created and staged_bytes is not None, + "modified": self._note_modified(state.path, state.before_bytes, state.path), + }, + ) + note_path = await self._resolve_written_note(state, day, file_path) + if note_path is None: + self.context.response.metadata.update({"path": None, "created": False, "modified": False}) + return None + + modified = self._note_modified(state.path, state.before_bytes, note_path) + self.context.response.metadata.update({"path": note_path, "created": state.created, "modified": modified}) + await self._ensure_resource_frontmatter(note_path, file_path) + note_path = await self._rename_from_frontmatter_name( + note_path, + day, + file_path, + note_stem, + allow_rename=state.created, + ) + modified = self._note_modified(state.path, state.before_bytes, note_path) + self.context.response.metadata.update({"path": note_path, "created": state.created, "modified": modified}) + index_payload = await self._refresh_day_index(day) + self.context.response.metadata.update( + { + "path": note_path, + "created": state.created, + "modified": modified, + "session_id": note_stem, + "source_resource": self._source_resource_link(file_path), + "action": "added" if added else "modified", + "index": index_payload, + }, + ) + return note_path + async def _handle_delete(self, file_path: str, date_str: str, note_stem: str) -> None: - daily_dir = self.config_value("daily_dir") - fallback_path = f"{daily_dir}/{date_str}/{note_stem}.md" - try: - note = await self._list_resource_note(date_str, file_path, fallback_path) - except RuntimeError as exc: - self.context.response.success = False - self.context.response.answer = str(exc) - self.logger.info(f"[{self.name}] delete list failed file_path={file_path} answer={str(exc)!r}") + note = await self._list_resource_note(date_str, file_path) + if note is None: + self.context.response.success = True + self.context.response.answer = f"No linked resource note to delete: {file_path}" + self.context.response.metadata.update( + { + "path": None, + "session_id": note_stem, + "source_resource": self._source_resource_link(file_path), + "action": "skipped", + "reason": "resource_note_not_found", + "modified": False, + }, + ) + self.logger.info(f"[{self.name}] delete skipped; no owned note file_path={file_path}") return - note_rel = str(note["path"]) if note else fallback_path + note_rel = str(note["path"]) note_abs = self.workspace_path / note_rel note_existed = note_abs.is_file() self.logger.info(f"[{self.name}] delete start note={note_rel}") @@ -265,12 +427,6 @@ class BaseAutoResourceStep(BaseStep): note_abs.unlink() self.logger.info(f"[{self.name}] Deleted file: {note_rel}") - await self.file_store.delete([note_rel]) - self.logger.info(f"[{self.name}] catalog delete done note={note_rel}") - index_payload = await self._refresh_day_index(date_str) - - self.context.response.success = True - self.context.response.answer = f"Deleted resource note: {note_rel}" self.context.response.metadata.update( { "path": note_rel, @@ -278,9 +434,15 @@ class BaseAutoResourceStep(BaseStep): "source_resource": self._source_resource_link(file_path), "action": "deleted", "modified": note_existed, - "index": index_payload, }, ) + await self.file_store.delete([note_rel]) + self.logger.info(f"[{self.name}] catalog delete done note={note_rel}") + index_payload = await self._refresh_day_index(date_str) + + self.context.response.success = True + self.context.response.answer = f"Deleted resource note: {note_rel}" + self.context.response.metadata["index"] = index_payload @abstractmethod async def _handle_upsert( @@ -289,6 +451,7 @@ class BaseAutoResourceStep(BaseStep): date_str: str, note_stem: str, added: bool, + source_path: Path, ) -> None: """Interpret one added or modified resource into its daily note.""" @@ -297,7 +460,6 @@ class BaseAutoResourceStep(BaseStep): # Handlers write item-scoped fields into the shared response. Start each # change with a fresh mapping so one result cannot inherit another's metadata. self.context.response.metadata = {} - file_path = self.to_workspace_relative(file_path) if file_path and Path(file_path).is_absolute() else file_path if not file_path: self.context.response.success = False self.context.response.answer = "Missing file_path" @@ -311,7 +473,23 @@ class BaseAutoResourceStep(BaseStep): self.logger.warning(f"[{self.name}] invalid change file_path={file_path} change={raw_change!r}") return {"success": False, "path": file_path, "change": raw_change, "answer": self.context.response.answer} - resource_dir = self.config_value("resource_dir") + try: + file_path, source_path, resource_dir = self._resolve_resource_source(file_path) + except ValueError as exc: + self.context.response.success = False + self.context.response.answer = str(exc) + self.context.response.metadata.update( + {"path": str(file_path), "action": "failed", "error": str(exc), "modified": False}, + ) + self.logger.warning(f"[{self.name}] invalid resource path file_path={file_path!r} error={exc}") + return { + "success": False, + "path": str(file_path), + "change": change.name, + "answer": self.context.response.answer, + "metadata": dict(self.context.response.metadata), + } + loose_filename = _loose_resource_filename(file_path, resource_dir) if loose_filename: date_str, filename = self._today(), loose_filename @@ -336,6 +514,7 @@ class BaseAutoResourceStep(BaseStep): date_str, note_stem, change == Change.added, + source_path, ) return { "success": self.context.response.success, diff --git a/reme/steps/index/_watch_rules.py b/reme/steps/index/_watch_rules.py index 352c2f92..f8c22d67 100644 --- a/reme/steps/index/_watch_rules.py +++ b/reme/steps/index/_watch_rules.py @@ -92,6 +92,7 @@ def match_file(file_path: str, rules: list[WatchRule]) -> bool: def _match_rule(p: Path, rule: WatchRule) -> bool: """Check if a single path matches a rule's suffix constraint.""" - if rule.suffixes and not any(p.name.endswith("." + s.strip(".")) for s in rule.suffixes): + filename = p.name.casefold() + if rule.suffixes and not any(filename.endswith("." + suffix.strip(".").casefold()) for suffix in rule.suffixes): return False return True diff --git a/tests/unit/auto_resource_test_support.py b/tests/unit/auto_resource_test_support.py new file mode 100644 index 00000000..7db8c4cd --- /dev/null +++ b/tests/unit/auto_resource_test_support.py @@ -0,0 +1,269 @@ +"""Shared test harness for auto-resource processor and router tests.""" + +import io +import json +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest_asyncio +from agentscope.model import ChatModelBase +from PIL import Image + +from reme.components import R +from reme.components.agent_wrapper import BaseAgentWrapper +from reme.components.file_store import LocalFileStore +from reme.components.runtime_context import RuntimeContext +from reme.steps.evolve.auto_image_resource import AutoImageResourceStep +from reme.steps.evolve.auto_resource import AutoResourceStep +from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep +from reme.steps.file_io import DailyListStep, FrontmatterUpdateStep, MoveStep, WriteStep + + +class FakeAgentWrapper(BaseAgentWrapper): + """Capture text-processor calls without invoking a real model.""" + + def __init__(self): + super().__init__() + self.inputs = "" + + async def reply(self, inputs, **_kwargs) -> dict: + """Record and accept one text-processor request.""" + self.inputs = inputs + return {"result": "ok"} + + +class FlakyAgentWrapper(BaseAgentWrapper): + """Fail one text item, then succeed.""" + + def __init__(self): + super().__init__() + self.calls = 0 + + async def reply(self, _inputs, **_kwargs) -> dict: + """Fail the first request and accept subsequent ones.""" + self.calls += 1 + if self.calls == 1: + raise RuntimeError("text provider unavailable") + return {"result": "recovered"} + + +class FakeVisionModel(ChatModelBase): + """Capture VLM calls and return canned plain text.""" + + def __init__(self, text: str): + self.text = text + self.calls: list = [] + + async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument + """Force callers through the plain fallback.""" + raise NotImplementedError("structured path not faked") + + async def __call__(self, messages, **kwargs): + """Record a call and return the canned plain text.""" + self.calls.append(messages) + return SimpleNamespace(content=[{"type": "text", "text": self.text}]) + + +class FlakyVisionModel(ChatModelBase): + """Fail the first plain call, then succeed.""" + + def __init__(self, text: str): + self.text = text + self.calls = 0 + + async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument + """Force callers through the plain fallback.""" + raise NotImplementedError("structured path not faked") + + async def __call__(self, messages, **kwargs): + """Fail once, then return the canned plain text.""" + self.calls += 1 + if self.calls == 1: + raise RuntimeError("vision backend unavailable") + return SimpleNamespace(content=[{"type": "text", "text": self.text}]) + + +class StructuredVisionModel(ChatModelBase): + """Serve structured output and count fallback plain calls.""" + + def __init__(self, content: dict | None = None, error: Exception | None = None, plain_text: str = "plain"): + self.content = content + self.error = error + self.plain_text = plain_text + self.structured_calls: list = [] + self.plain_calls: list = [] + + async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument + """Return or fail with the configured structured response.""" + self.structured_calls.append(messages) + if self.error is not None: + raise self.error + return SimpleNamespace(content=dict(self.content or {})) + + async def __call__(self, messages, **kwargs): # pylint: disable=unused-argument + """Record and return the configured plain fallback.""" + self.plain_calls.append(messages) + return SimpleNamespace(content=[{"type": "text", "text": self.plain_text}]) + + +class FakeAudioResourceStep(BaseAutoResourceStep): + """Minimal third modality used to verify the router extension contract.""" + + resource_suffixes = frozenset({".wav"}) + + async def _handle_upsert( + self, + file_path: str, + date_str: str, + note_stem: str, + added: bool, + source_path: Path, + ) -> None: + del source_path + self.context.response.success = True + self.context.response.answer = f"Processed audio resource: {file_path}" + self.context.response.metadata.update( + { + "path": f"daily/{date_str}/{note_stem}.md", + "action": "added" if added else "modified", + "processor": "audio", + "modified": True, + }, + ) + + +class _StepJob: + """Tiny job adapter for tests that need ``BaseStep.run_job``.""" + + def __init__(self, step_cls, app_context, file_store): + self.step_cls = step_cls + self.app_context = app_context + self.file_store = file_store + + async def __call__(self, **kwargs): + step = self.step_cls(app_context=self.app_context, file_store=self.file_store) + result = await step(**kwargs) + return result or step.context.response + + +def make_app_context(workspace: Path): + """Create the minimal application context used by resource tests.""" + context = MagicMock() + context.app_config.workspace_dir = str(workspace) + context.app_config.daily_dir = "daily" + context.app_config.digest_dir = "digest" + context.app_config.resource_dir = "resource" + context.app_config.session_dir = "session" + context.app_config.timezone = None + return context + + +def _install_file_jobs(app_context, file_store) -> None: + app_context.jobs = { + "daily_list": _StepJob(DailyListStep, app_context, file_store), + "frontmatter_update": _StepJob(FrontmatterUpdateStep, app_context, file_store), + "move": _StepJob(MoveStep, app_context, file_store), + "write": _StepJob(WriteStep, app_context, file_store), + } + + +def image_bytes(image_format: str = "PNG", size=(8, 8), color=(200, 30, 30)) -> bytes: + """Synthesize a small image in a Pillow-supported format.""" + if image_format == "HEIF": + from pillow_heif import register_heif_opener + + register_heif_opener() + image = Image.new("RGB", size, color) + buffer = io.BytesIO() + image.save(buffer, format=image_format) + return buffer.getvalue() + + +def png_bytes(width: int = 8, height: int = 8, color=(200, 30, 30)) -> bytes: + """Compatibility shorthand for PNG-focused assertions.""" + return image_bytes("PNG", (width, height), color) + + +def write_binary(path: Path, data: bytes) -> Path: + """Write test bytes, creating parent directories.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def write_note(path: Path, source_resource: str, body: str = "old caption") -> Path: + """Write a minimal source-owned image note.""" + content = ( + f"---\nname: {path.stem}\ndescription: old\n" + f'source_resource: "{source_resource}"\nkind: image\n' + f"media_type: image/png\n---\n![[{source_resource[2:-2]}]]\n\n## Caption\n\n{body}\n" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def caption_json(name: str, description: str, caption: str) -> str: + """Build a plain-call caption payload.""" + return json.dumps({"name": name, "description": description, "caption": caption}) + + +def image_processor(app_context, file_store, model, *, routed: bool): + """Build either the image processor or the public unified-router path.""" + if not routed: + return AutoImageResourceStep(app_context=app_context, file_store=file_store, as_llm=model) + app_context.registry = R + return AutoResourceStep( + app_context=app_context, + dispatch_steps=[ + {"backend": "auto_image_resource_step", "file_store": file_store, "as_llm": model}, + { + "backend": "auto_text_resource_step", + "file_store": file_store, + "agent_wrapper": FakeAgentWrapper(), + }, + ], + ) + + +@dataclass +class AutoResourceTestEnv: + """Started, isolated workspace shared by one test invocation.""" + + workspace: Path + app_context: object + file_store: LocalFileStore + + def write_binary(self, relative_path: str, data: bytes) -> Path: + """Write bytes relative to this workspace.""" + return write_binary(self.workspace / relative_path, data) + + def write_note(self, relative_path: str, source_resource: str, body: str = "old caption") -> Path: + """Write a source-owned note relative to this workspace.""" + return write_note(self.workspace / relative_path, source_resource, body) + + def processor(self, model, *, routed: bool = False): + """Build the direct processor or unified router for this workspace.""" + return image_processor(self.app_context, self.file_store, model, routed=routed) + + async def run(self, step, changes, **context_kwargs): + """Run one processor invocation with a fresh runtime context.""" + return await step(RuntimeContext(changes=changes, **context_kwargs)) + + +@pytest_asyncio.fixture +async def auto_resource_env(tmp_path, monkeypatch): + """Yield a started resource-test workspace and always close its file store.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.chdir(workspace) + app_context = make_app_context(workspace) + file_store = LocalFileStore(name="test_store", embedding_store="") + await file_store.start() + _install_file_jobs(app_context, file_store) + try: + yield AutoResourceTestEnv(workspace, app_context, file_store) + finally: + await file_store.close() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..869fbb02 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,5 @@ +"""Fixtures shared by focused unit-test modules.""" + +from .auto_resource_test_support import auto_resource_env + +__all__ = ["auto_resource_env"] diff --git a/tests/unit/test_auto_image_steps.py b/tests/unit/test_auto_image_steps.py index 45ec1270..44defb75 100644 --- a/tests/unit/test_auto_image_steps.py +++ b/tests/unit/test_auto_image_steps.py @@ -6,34 +6,26 @@ with PIL inside a temporary workspace. # pylint: disable=protected-access -import asyncio import base64 import hashlib import io -import json -import os import subprocess import sys -import tempfile import tomllib from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import frontmatter import pytest import yaml -from agentscope.model import ChatModelBase from PIL import Image from reme.components import R -from reme.components.agent_wrapper import BaseAgentWrapper from reme.components.component_registry import ComponentRegistry -from reme.components.file_store import LocalFileStore from reme.components.job import BaseJob from reme.components.runtime_context import RuntimeContext from reme.enumeration import ComponentEnum -from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep from reme.steps.evolve.auto_image_resource import ( AutoImageResourceStep, _build_image_request_payload, @@ -42,602 +34,288 @@ from reme.steps.evolve.auto_image_resource import ( ) from reme.steps.evolve.auto_resource import AutoResourceStep from reme.steps.evolve.auto_text_resource import AutoTextResourceStep -from reme.steps.file_io import DailyListStep, FrontmatterUpdateStep, MoveStep, WriteStep +from .auto_resource_test_support import ( + FakeAgentWrapper as _FakeAgentWrapper, + FakeAudioResourceStep as _FakeAudioResourceStep, + FakeVisionModel as _FakeVisionModel, + FlakyAgentWrapper as _FlakyAgentWrapper, + FlakyVisionModel as _FlakyVisionModel, + StructuredVisionModel as _StructuredVisionModel, + caption_json as _caption_json, + image_bytes as _img_bytes, + make_app_context as _make_app_context, + png_bytes as _png_bytes, + write_binary as _write_binary, + write_note as _write_note, +) -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) - - -class _FakeAgentWrapper(BaseAgentWrapper): - """Capture agent calls without invoking a real model.""" - - def __init__(self): - super().__init__() - self.inputs = "" - - async def reply(self, inputs, **_kwargs) -> dict: - """Capture the text processor input and return a successful result.""" - self.inputs = inputs - return {"result": "ok"} - - -class _FlakyAgentWrapper(BaseAgentWrapper): - """Fail one text item, then succeed so per-resource isolation is observable.""" - - def __init__(self): - super().__init__() - self.calls = 0 - - async def reply(self, _inputs, **_kwargs) -> dict: - """Raise on the first call and return normally on later calls.""" - self.calls += 1 - if self.calls == 1: - raise RuntimeError("text provider unavailable") - return {"result": "recovered"} - - -class _FakeVisionModel(ChatModelBase): - """Capture VLM calls and return a canned text response (plain-call path).""" - - def __init__(self, text: str): - self.text = text - self.calls: list = [] - - async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument - """Force the plain-call path in tests.""" - raise NotImplementedError("structured path not faked") - - async def __call__(self, messages, **kwargs): - self.calls.append(messages) - return SimpleNamespace(content=[{"type": "text", "text": self.text}]) - - -class _FakeAudioResourceStep(BaseAutoResourceStep): - """Minimal third-modality processor used to verify the router extension contract.""" - - resource_suffixes = frozenset({".wav"}) - - async def _handle_upsert(self, file_path: str, date_str: str, note_stem: str, added: bool) -> None: - self.context.response.success = True - self.context.response.answer = f"Processed audio resource: {file_path}" - self.context.response.metadata.update( - { - "path": f"daily/{date_str}/{note_stem}.md", - "action": "added" if added else "modified", - "processor": "audio", - "modified": True, - }, - ) - - -class _FlakyVisionModel(ChatModelBase): - """Fail the first plain call, succeed afterwards.""" - - def __init__(self, text: str): - self.text = text - self.calls = 0 - - async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument - """Force the plain-call path in tests.""" - raise NotImplementedError("structured path not faked") - - async def __call__(self, messages, **kwargs): - self.calls += 1 - if self.calls == 1: - raise RuntimeError("vision backend unavailable") - return SimpleNamespace(content=[{"type": "text", "text": self.text}]) - - -class _StructuredVisionModel(ChatModelBase): - """Serve the schema-forced structured path; count fallback plain calls.""" - - def __init__(self, content: dict | None = None, error: Exception | None = None, plain_text: str = "plain"): - self.content = content - self.error = error - self.plain_text = plain_text - self.structured_calls: list = [] - self.plain_calls: list = [] - - async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument - """Serve the canned structured content (or raise the canned error).""" - self.structured_calls.append(messages) - if self.error is not None: - raise self.error - return SimpleNamespace(content=dict(self.content or {})) - - async def __call__(self, messages, **kwargs): # pylint: disable=unused-argument - """Serve the canned plain-call text response.""" - self.plain_calls.append(messages) - return SimpleNamespace(content=[{"type": "text", "text": self.plain_text}]) - - -class _StepJob: - """Tiny job adapter for unit tests that need BaseStep.run_job.""" - - def __init__(self, step_cls, app_context, file_store): - self.step_cls = step_cls - self.app_context = app_context - self.file_store = file_store - - async def __call__(self, **kwargs): - step = self.step_cls(app_context=self.app_context, file_store=self.file_store) - result = await step(**kwargs) - return result or step.context.response - - -def _make_app_context(workspace_path: Path): - """Create a mock app_context with app_config pointing to the given workspace.""" - ctx = MagicMock() - ctx.app_config.workspace_dir = str(workspace_path) - ctx.app_config.daily_dir = "daily" - ctx.app_config.digest_dir = "digest" - ctx.app_config.resource_dir = "resource" - ctx.app_config.session_dir = "session" - ctx.app_config.timezone = None - return ctx - - -def _install_file_jobs(app_context, file_store) -> None: - app_context.jobs = { - "daily_list": _StepJob(DailyListStep, app_context, file_store), - "frontmatter_update": _StepJob(FrontmatterUpdateStep, app_context, file_store), - "move": _StepJob(MoveStep, app_context, file_store), - "write": _StepJob(WriteStep, app_context, file_store), - } - - -def _png_bytes(width: int = 8, height: int = 8, color=(200, 30, 30)) -> bytes: - image = Image.new("RGB", (width, height), color) - buffer = io.BytesIO() - image.save(buffer, format="PNG") - return buffer.getvalue() - - -def _img_bytes(image_format: str, size=(8, 8), color=(200, 30, 30)) -> bytes: - """Synthesize an image in any PIL-supported format (incl. HEIF via pillow-heif).""" - if image_format == "HEIF": - from pillow_heif import register_heif_opener - - register_heif_opener() - image = Image.new("RGB", size, color) - buffer = io.BytesIO() - image.save(buffer, format=image_format) - return buffer.getvalue() - - -def _write_binary(path: Path, data: bytes) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - return path - - -def _write_note(path: Path, source_resource: str, body: str = "old caption") -> Path: - content = ( - f"---\nname: {path.stem}\ndescription: old\n" - f'source_resource: "{source_resource}"\nkind: image\n' - f"media_type: image/png\n---\n![[{source_resource[2:-2]}]]\n\n## Caption\n\n{body}\n" +@pytest.mark.parametrize( + ("image_format", "suffix", "source_mime", "request_mime"), + [ + ("PNG", ".png", "image/png", "image/png"), + ("JPEG", ".jpg", "image/jpeg", "image/jpeg"), + ("WEBP", ".webp", "image/webp", "image/webp"), + ("BMP", ".bmp", "image/bmp", "image/jpeg"), + ("TIFF", ".tiff", "image/tiff", "image/jpeg"), + ], +) +@pytest.mark.asyncio +async def test_auto_image_supports_core_formats( + image_format, + suffix, + source_mime, + request_mime, + auto_resource_env, +): + """Core formats preserve source metadata and use a provider-safe request payload.""" + env = auto_resource_env + source = env.write_binary(f"resource/2026-01-01/image{suffix}", _img_bytes(image_format)) + model = _StructuredVisionModel( + content={"name": "visible-subject", "description": "Visible", "caption": "Visible caption."}, ) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return path + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) + + assert response.success is True + note_path = env.workspace / "daily/2026-01-01/visible-subject.md" + post = frontmatter.loads(note_path.read_text(encoding="utf-8")) + assert post.metadata["source_resource"] == f"[[resource/2026-01-01/image{suffix}]]" + assert post.metadata["kind"] == "image" + assert post.metadata["media_type"] == source_mime + assert post.metadata["name"] == "visible-subject" + assert f"![[resource/2026-01-01/image{suffix}]]" in post.content + assert "Visible caption." in post.content + assert model.structured_calls[0][0].content[1].source.media_type == request_mime + assert (env.workspace / "daily/2026-01-01.md").is_file() -def _caption_json(name: str, description: str, caption: str) -> str: - return json.dumps({"name": name, "description": description, "caption": caption}) +@pytest.mark.parametrize("change", ["added", "modified", "deleted"]) +@pytest.mark.asyncio +async def test_auto_image_note_lifecycle(change, auto_resource_env): + """Added, modified, and deleted image events maintain one source-owned note.""" + env = auto_resource_env + source = env.workspace / "resource/2026-01-01/img.png" + note_path = env.workspace / "daily/2026-01-01/red-square.md" + if change != "deleted": + _write_binary(source, _png_bytes()) + if change != "added": + _write_note(note_path, "[[resource/2026-01-01/img.png]]") + + model = _FakeVisionModel(_caption_json("red-square", "Updated", "The updated caption.")) + response = await env.run(env.processor(model), [{"change": change, "path": str(source)}]) + + result = response.metadata["results"][0]["metadata"] + assert response.success is True + assert result["action"] == change + if change == "deleted": + assert not note_path.exists() + assert not model.calls + else: + assert note_path.is_file() + assert "The updated caption." in frontmatter.loads(note_path.read_text(encoding="utf-8")).content -def _run_step(step, changes, **context_kwargs): - return step(RuntimeContext(changes=changes, **context_kwargs)) +@pytest.mark.parametrize( + ("stem", "plain_text", "note_name", "caption", "raw_json_must_be_absent"), + [ + ( + "fenced", + "```json\n" + _caption_json("fenced-note", "Fenced", "Fenced caption body.") + "\n```", + "fenced-note", + "Fenced caption body.", + False, + ), + ("photo", "A plain description.", "photo", "A plain description.", False), + ( + "waterfall", + '{"file": "resource/2026-01-01/waterfall.png", "description": "A tall waterfall."}', + "waterfall", + "A tall waterfall.", + True, + ), + ], +) +@pytest.mark.asyncio +async def test_auto_image_plain_outputs_create_clean_notes( + stem, + plain_text, + note_name, + caption, + raw_json_must_be_absent, + auto_resource_env, +): + """Fenced JSON, raw text, and description-only JSON remain valid plain fallbacks.""" + env = auto_resource_env + source = env.write_binary(f"resource/2026-01-01/{stem}.png", _png_bytes()) + response = await env.run(env.processor(_FakeVisionModel(plain_text)), [{"change": "added", "path": str(source)}]) + + assert response.success is True + content = (env.workspace / f"daily/2026-01-01/{note_name}.md").read_text(encoding="utf-8") + assert caption in content + if raw_json_must_be_absent: + assert '{"file"' not in content + assert '"description"' not in content -def test_auto_image_creates_caption_note(): - """An added image produces a renamed daily note with caption and embed link.""" - - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("red-square", "A red square", "An 8x8 solid red square.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - note_path = cwd / "daily" / "2026-01-01" / "red-square.md" - assert note_path.is_file() - post = frontmatter.loads(note_path.read_text(encoding="utf-8")) - assert post.metadata["source_resource"] == "[[resource/2026-01-01/img.png]]" - assert post.metadata["kind"] == "image" - assert post.metadata["media_type"] == "image/png" - assert post.metadata["name"] == "red-square" - assert "![[resource/2026-01-01/img.png]]" in post.content - assert "An 8x8 solid red square." in post.content - assert (cwd / "daily" / "2026-01-01.md").is_file() - assert len(model.calls) == 1 - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_parses_fenced_json_and_falls_back_to_raw_text(): - """Fenced JSON is parsed; non-JSON output degrades to a raw-text caption.""" - - 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: - fenced = "```json\n" + _caption_json("fenced-note", "Fenced", "Fenced caption body.") + "\n```" - source = _write_binary(cwd / "resource" / "2026-01-01" / "fenced.png", _png_bytes()) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=_FakeVisionModel(fenced)) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - assert resp.success is True - fenced_post = frontmatter.loads((cwd / "daily" / "2026-01-01" / "fenced-note.md").read_text("utf-8")) - assert "Fenced caption body." in fenced_post.content - - raw = _write_binary(cwd / "resource" / "2026-01-01" / "photo.png", _png_bytes(color=(30, 30, 200))) - step = AutoImageResourceStep( - app_context=app_ctx, - file_store=fs, - as_llm=_FakeVisionModel("A plain description."), - ) - resp = await _run_step(step, [{"change": "added", "path": str(raw)}]) - assert resp.success is True - raw_post = frontmatter.loads((cwd / "daily" / "2026-01-01" / "photo.md").read_text("utf-8")) - assert "A plain description." in raw_post.content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_updates_existing_note_in_place(): - """A modified image rewrites the same note found via 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="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - source = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - note_path = _write_note( - cwd / "daily" / "2026-01-01" / "red-square.md", - "[[resource/2026-01-01/img.png]]", - ) - model = _FakeVisionModel(_caption_json("red-square", "Updated", "The updated caption.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "modified", "path": str(source)}]) - - assert resp.success is True - assert note_path.is_file() - post = frontmatter.loads(note_path.read_text(encoding="utf-8")) - assert "The updated caption." in post.content - assert not (cwd / "daily" / "2026-01-01" / "img.md").exists() - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_deletes_linked_note(): - """Deleting the image resource removes its caption note.""" - - 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 = cwd / "resource" / "2026-01-01" / "img.png" - note_path = _write_note( - cwd / "daily" / "2026-01-01" / "red-square.md", - "[[resource/2026-01-01/img.png]]", - ) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=_FakeVisionModel("{}")) - resp = await _run_step(step, [{"change": "deleted", "path": str(source)}]) - - assert resp.success is True - assert not note_path.exists() - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_downscales_oversized_image_for_request_only(): +@pytest.mark.asyncio +async def test_auto_image_downscales_oversized_image_for_request_only(auto_resource_env): """Images beyond the request budget are downscaled in the request; storage is untouched.""" - 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_binary( - cwd / "resource" / "2026-01-01" / "huge.png", - _png_bytes(width=3000, height=3000), - ) - stored_bytes = source.read_bytes() - model = _FakeVisionModel(_caption_json("huge-image", "Big", "A big image.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/huge.png", _png_bytes(width=3000, height=3000)) + stored_bytes = source.read_bytes() + model = _FakeVisionModel(_caption_json("huge-image", "Big", "A big image.")) + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) - assert resp.success is True - assert len(model.calls) == 1 - data_block = model.calls[0][0].content[1] - assert data_block.source.media_type == "image/jpeg" - with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent: - assert max(sent.size) <= 2048 - assert source.read_bytes() == stored_bytes - assert (cwd / "daily" / "2026-01-01" / "huge-image.md").is_file() - finally: - await fs.close() - - asyncio.run(run()) + assert response.success is True + data_block = model.calls[0][0].content[1] + assert data_block.source.media_type == "image/jpeg" + with Image.open(io.BytesIO(base64.b64decode(data_block.source.data))) as sent: + assert max(sent.size) <= 2048 + assert source.read_bytes() == stored_bytes + assert (env.workspace / "daily/2026-01-01/huge-image.md").is_file() -def test_auto_image_skips_oversized_file(): +@pytest.mark.asyncio +async def test_auto_image_skips_oversized_file(auto_resource_env): """Files beyond max_image_bytes are skipped without a VLM 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="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - source = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("x", "y", "z")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}], max_image_bytes=8) + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + model = _FakeVisionModel(_caption_json("x", "y", "z")) + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}], max_image_bytes=8) - result = resp.metadata["results"][0] - assert resp.success is True - assert result["metadata"]["reason"] == "file_too_large" - assert result["metadata"]["oversized"] is True - assert not model.calls - assert not (cwd / "daily" / "2026-01-01" / "img.md").exists() - finally: - await fs.close() - - asyncio.run(run()) + result = response.metadata["results"][0] + assert response.success is True + assert result["metadata"]["reason"] == "file_too_large" + assert result["metadata"]["oversized"] is True + assert not model.calls + assert not (env.workspace / "daily/2026-01-01/img.md").exists() -def test_auto_image_skips_without_vision_model(): +@pytest.mark.asyncio +async def test_auto_image_skips_without_vision_model(auto_resource_env): """Without any resolvable vision model the change is skipped with a reason.""" - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.components = {} - fs = LocalFileStore(name="test_store", embedding_store="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - source = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) + env = auto_resource_env + env.app_context.components = {} + source = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + step = AutoImageResourceStep(app_context=env.app_context, file_store=env.file_store) + response = await env.run(step, [{"change": "added", "path": str(source)}]) - result = resp.metadata["results"][0] - assert resp.success is True - assert result["metadata"]["reason"] == "vision_model_not_configured" - assert not (cwd / "daily" / "2026-01-01" / "img.md").exists() - finally: - await fs.close() - - asyncio.run(run()) + result = response.metadata["results"][0] + assert response.success is True + assert result["metadata"]["reason"] == "vision_model_not_configured" + assert not (env.workspace / "daily/2026-01-01/img.md").exists() -def test_auto_resource_router_preserves_mixed_result_order_and_emits_one_hook(): +@pytest.mark.asyncio +async def test_auto_resource_router_preserves_mixed_result_order_and_emits_one_hook(auto_resource_env): """The unified router sends each suffix to one processor and aggregates once.""" + env = auto_resource_env + env.app_context.registry = R.copy() + env.app_context.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__) + image = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + text = env.workspace / "resource/2026-01-01/note.txt" + text.write_text("hello", encoding="utf-8") + wrapper = _FakeAgentWrapper() + model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) + hook_calls = [] - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.registry = R.copy() - app_ctx.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__) - fs = LocalFileStore(name="test_store", embedding_store="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - image = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - text = cwd / "resource" / "2026-01-01" / "note.txt" - text.parent.mkdir(parents=True, exist_ok=True) - text.write_text("hello", encoding="utf-8") - wrapper = _FakeAgentWrapper() - model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - hook_calls = [] + async def hook(**kwargs): + hook_calls.append(kwargs) - async def hook(**kwargs): - hook_calls.append(kwargs) + env.app_context.metadata = {"qwenpaw_memory_result_hook": hook} + changes = [ + {"change": "added", "path": str(image)}, + {"change": "added", "path": str(env.workspace / "resource/2026-01-01/clip.wav")}, + {"change": "added", "path": str(text)}, + ] + step = AutoResourceStep( + app_context=env.app_context, + file_store=env.file_store, + agent_wrapper=wrapper, + as_llm=model, + dispatch_steps=["auto_image_resource_step", "fake_audio_resource_step", "auto_text_resource_step"], + ) + context = RuntimeContext(changes=changes) + response = await step(context) - app_ctx.metadata = {"qwenpaw_memory_result_hook": hook} - changes = [ - {"change": "added", "path": str(image)}, - {"change": "added", "path": str(cwd / "resource" / "2026-01-01" / "clip.wav")}, - {"change": "added", "path": str(text)}, - ] - step = AutoResourceStep( - app_context=app_ctx, - file_store=fs, - agent_wrapper=wrapper, - as_llm=model, - dispatch_steps=[ - "auto_image_resource_step", - "fake_audio_resource_step", - "auto_text_resource_step", - ], - ) - context = RuntimeContext(changes=changes) - resp = await step(context) - - assert resp.success is True - assert [item["path"] for item in resp.metadata["results"]] == [ - "resource/2026-01-01/img.png", - "resource/2026-01-01/clip.wav", - "resource/2026-01-01/note.txt", - ] - assert all( - (item.get("metadata") or {}).get("reason") not in {"image_file", "non_image_file"} - for item in resp.metadata["results"] - ) - assert len(model.calls) == 1 - assert "hello" in wrapper.inputs - assert context.get("changes") == changes - assert len(hook_calls) == 1 - assert hook_calls[0]["kwargs"] == {"changes": changes} - assert len(hook_calls[0]["metadata"]["results"]) == 3 - finally: - await fs.close() - - asyncio.run(run()) + assert response.success is True + assert [item["path"] for item in response.metadata["results"]] == [ + "resource/2026-01-01/img.png", + "resource/2026-01-01/clip.wav", + "resource/2026-01-01/note.txt", + ] + assert len(model.calls) == 1 + assert "hello" in wrapper.inputs + assert context.get("changes") == changes + assert len(hook_calls) == 1 + assert hook_calls[0]["kwargs"] == {"changes": changes} + assert len(hook_calls[0]["metadata"]["results"]) == 3 -def test_auto_resource_router_does_not_mask_text_failure_with_image_success(): - """A later successful image result cannot overwrite an earlier text failure.""" - - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.registry = R - fs = LocalFileStore(name="test_store", embedding_store="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - missing_text = cwd / "resource" / "2026-01-01" / "missing.txt" - image = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - changes = [ - {"change": "added", "path": str(missing_text)}, - {"change": "added", "path": str(image)}, - ] - step = AutoResourceStep( - app_context=app_ctx, - dispatch_steps=[ - {"backend": "auto_image_resource_step", "file_store": fs, "as_llm": model}, - { - "backend": "auto_text_resource_step", - "file_store": fs, - "agent_wrapper": _FakeAgentWrapper(), - }, - ], - ) - resp = await step(RuntimeContext(changes=changes)) - - assert resp.success is False - assert resp.metadata["results"][0]["success"] is False - assert "Resource file not found" in resp.metadata["results"][0]["answer"] - assert resp.metadata["results"][1]["success"] is True - assert (cwd / "daily" / "2026-01-01" / "red-square.md").is_file() - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_resource_router_isolates_text_exception_and_preserves_image_result(): +@pytest.mark.asyncio +async def test_auto_resource_router_isolates_text_exception_and_preserves_image_result(auto_resource_env): """One text exception cannot discard an earlier image write or stop later resources.""" - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.registry = R.copy() - fs = LocalFileStore(name="test_store", embedding_store="") - await fs.start() - _install_file_jobs(app_ctx, fs) - try: - image = _write_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - first_text = cwd / "resource" / "2026-01-01" / "first.txt" - second_text = cwd / "resource" / "2026-01-01" / "second.txt" - first_text.write_text("first", encoding="utf-8") - second_text.write_text("second", encoding="utf-8") - model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - wrapper = _FlakyAgentWrapper() - hook_calls = [] + env = auto_resource_env + env.app_context.registry = R.copy() + image = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + first_text = env.workspace / "resource/2026-01-01/first.txt" + second_text = env.workspace / "resource/2026-01-01/second.txt" + first_text.write_text("first", encoding="utf-8") + second_text.write_text("second", encoding="utf-8") + model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) + wrapper = _FlakyAgentWrapper() + hook_calls = [] - async def hook(**kwargs): - hook_calls.append(kwargs) + async def hook(**kwargs): + hook_calls.append(kwargs) - app_ctx.metadata = {"qwenpaw_memory_result_hook": hook} - changes = [ - {"change": "added", "path": str(first_text)}, - {"change": "added", "path": str(image)}, - {"change": "added", "path": str(second_text)}, - ] - context = RuntimeContext(changes=changes) - step = AutoResourceStep( - app_context=app_ctx, - dispatch_steps=[ - {"backend": "auto_image_resource_step", "file_store": fs, "as_llm": model}, - { - "backend": "auto_text_resource_step", - "file_store": fs, - "agent_wrapper": wrapper, - }, - ], - ) - response = await step(context) + env.app_context.metadata = {"qwenpaw_memory_result_hook": hook} + changes = [ + {"change": "added", "path": str(first_text)}, + {"change": "added", "path": str(image)}, + {"change": "added", "path": str(second_text)}, + ] + context = RuntimeContext(changes=changes) + step = AutoResourceStep( + app_context=env.app_context, + dispatch_steps=[ + {"backend": "auto_image_resource_step", "file_store": env.file_store, "as_llm": model}, + { + "backend": "auto_text_resource_step", + "file_store": env.file_store, + "agent_wrapper": wrapper, + }, + ], + ) + response = await step(context) - results = response.metadata["results"] - assert response.success is False - assert response.metadata["processed"] == 3 - assert response.metadata["modified"] is True - assert [item["path"] for item in results] == [ - "resource/2026-01-01/first.txt", - "resource/2026-01-01/img.png", - "resource/2026-01-01/second.txt", - ] - assert [item["success"] for item in results] == [False, True, True] - assert results[0]["metadata"] == { - "path": "resource/2026-01-01/first.txt", - "modified": False, - "action": "failed", - "error": "text provider unavailable", - } - assert results[1]["metadata"]["modified"] is True - assert "error" not in results[2]["metadata"] - assert wrapper.calls == 2 - assert (cwd / "daily" / "2026-01-01" / "red-square.md").is_file() - assert context.get("changes") == changes - assert len(hook_calls) == 1 - assert hook_calls[0]["metadata"]["results"] == results - finally: - await fs.close() - - asyncio.run(run()) + results = response.metadata["results"] + assert response.success is False + assert response.metadata["processed"] == 3 + assert response.metadata["modified"] is True + assert [item["path"] for item in results] == [ + "resource/2026-01-01/first.txt", + "resource/2026-01-01/img.png", + "resource/2026-01-01/second.txt", + ] + assert [item["success"] for item in results] == [False, True, True] + assert results[0]["metadata"] == { + "path": "resource/2026-01-01/first.txt", + "modified": False, + "action": "failed", + "error": "text provider unavailable", + } + assert results[1]["metadata"]["modified"] is True + assert "error" not in results[2]["metadata"] + assert wrapper.calls == 2 + assert (env.workspace / "daily/2026-01-01/red-square.md").is_file() + assert context.get("changes") == changes + assert len(hook_calls) == 1 + assert hook_calls[0]["metadata"]["results"] == results def test_auto_resource_router_inherits_declared_options_with_child_override(): @@ -678,43 +356,31 @@ def test_auto_resource_router_inherits_declared_options_with_child_override(): } -def test_auto_resource_router_accepts_a_registered_third_modality_without_code_changes(): +@pytest.mark.asyncio +async def test_auto_resource_router_accepts_a_registered_third_modality_without_code_changes(auto_resource_env): """A new processor only needs registration, a matcher, and dispatch configuration.""" - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.registry = R.copy() - app_ctx.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__) - step = AutoResourceStep( - app_context=app_ctx, - dispatch_steps=["fake_audio_resource_step", "auto_text_resource_step"], - ) - response = await step( - RuntimeContext( - changes=[{"change": "added", "path": str(cwd / "resource" / "2026-01-01" / "clip.WAV")}], - ), - ) + env = auto_resource_env + env.app_context.registry = R.copy() + env.app_context.registry.add("fake_audio_resource_step", _FakeAudioResourceStep, owner=__name__) + step = AutoResourceStep( + app_context=env.app_context, + dispatch_steps=["fake_audio_resource_step", "auto_text_resource_step"], + ) + response = await env.run( + step, + [{"change": "added", "path": str(env.workspace / "resource/2026-01-01/clip.WAV")}], + ) - assert response.success is True - assert response.metadata["results"][0]["metadata"]["processor"] == "audio" - assert response.metadata["results"][0]["path"] == "resource/2026-01-01/clip.WAV" + assert response.success is True + assert response.metadata["results"][0]["metadata"]["processor"] == "audio" + assert response.metadata["results"][0]["path"] == "resource/2026-01-01/clip.WAV" - unsupported = AutoResourceStep( - app_context=app_ctx, - dispatch_steps=["fake_audio_resource_step"], - ) - response = await unsupported( - RuntimeContext( - changes=[{"change": "added", "path": "resource/2026-01-01/archive.bin"}], - ), - ) + unsupported = AutoResourceStep(app_context=env.app_context, dispatch_steps=["fake_audio_resource_step"]) + response = await env.run(unsupported, [{"change": "added", "path": "resource/2026-01-01/archive.bin"}]) - assert response.success is False - assert response.metadata["results"][0]["metadata"]["reason"] == "unsupported_resource" - - asyncio.run(run()) + assert response.success is False + assert response.metadata["results"][0]["metadata"]["reason"] == "unsupported_resource" def test_auto_resource_router_requires_the_fallback_processor_to_be_last(): @@ -740,46 +406,37 @@ def test_auto_resource_router_discovers_unique_fallback_for_legacy_config(): ] -def test_auto_resource_legacy_job_config_dispatches_text_resource(): +@pytest.mark.asyncio +async def test_auto_resource_legacy_job_config_dispatches_text_resource(auto_resource_env): """A real BaseJob accepts the pre-router auto_resource Step configuration.""" - async def run(): - with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): - cwd = Path.cwd() - app_ctx = _make_app_context(cwd) - app_ctx.registry = R.copy() - fs = LocalFileStore(name="test_store", embedding_store="") - await fs.start() - _install_file_jobs(app_ctx, fs) - wrapper = _FakeAgentWrapper() - job = BaseJob( - name="legacy_auto_resource", - app_context=app_ctx, - steps=[ - { - "backend": "auto_resource_step", - "file_store": fs, - "agent_wrapper": wrapper, - }, - ], - ) - await job.start() - try: - source = cwd / "resource" / "2026-01-01" / "legacy.txt" - source.parent.mkdir(parents=True, exist_ok=True) - source.write_text("legacy text resource", encoding="utf-8") + env = auto_resource_env + env.app_context.registry = R.copy() + wrapper = _FakeAgentWrapper() + job = BaseJob( + name="legacy_auto_resource", + app_context=env.app_context, + steps=[ + { + "backend": "auto_resource_step", + "file_store": env.file_store, + "agent_wrapper": wrapper, + }, + ], + ) + await job.start() + try: + source = env.workspace / "resource/2026-01-01/legacy.txt" + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("legacy text resource", encoding="utf-8") + response = await job(changes=[{"change": "added", "path": str(source)}]) - response = await job(changes=[{"change": "added", "path": str(source)}]) - - assert response.success is True - assert response.metadata["processed"] == 1 - assert response.metadata["results"][0]["success"] is True - assert "legacy text resource" in wrapper.inputs - finally: - await job.close() - await fs.close() - - asyncio.run(run()) + assert response.success is True + assert response.metadata["processed"] == 1 + assert response.metadata["results"][0]["success"] is True + assert "legacy text resource" in wrapper.inputs + finally: + await job.close() def test_auto_resource_router_rejects_ambiguous_registered_fallbacks(): @@ -851,36 +508,45 @@ def test_auto_image_named_model_uses_standard_ref_resolution(): missing._vision_model() -def test_image_preprocessing_reports_dependency_and_decode_errors(): - """Lazy image dependencies and corrupt bytes produce actionable errors.""" +@pytest.mark.parametrize( + ("blocked_module", "suffix", "error_pattern"), + [ + ("PIL", ".png", r"Pillow.*reme-ai\[core\]"), + ("pillow_heif", ".heic", r"pillow-heif.*reme-ai\[image-heif\]"), + ], +) +def test_image_preprocessing_reports_dependency_errors(blocked_module, suffix, error_pattern): + """Lazy image dependencies produce actionable installation errors.""" real_import = __import__ - def import_without_pillow(name, *args, **kwargs): - if name == "PIL": - raise ImportError("blocked Pillow") + def import_without_dependency(name, *args, **kwargs): + if name == blocked_module or (blocked_module == "PIL" and name.startswith("PIL.")): + raise ImportError(f"blocked {blocked_module}") return real_import(name, *args, **kwargs) - with patch("builtins.__import__", side_effect=import_without_pillow): - with pytest.raises(RuntimeError, match=r"Pillow.*reme-ai\[core\]"): - _normalize_image_bytes(_png_bytes(), ".png") + with patch("builtins.__import__", side_effect=import_without_dependency): + with pytest.raises(RuntimeError, match=error_pattern): + _normalize_image_bytes(_png_bytes(), suffix) - def import_without_heif(name, *args, **kwargs): - if name == "pillow_heif": - raise ImportError("blocked pillow-heif") - return real_import(name, *args, **kwargs) - with patch("builtins.__import__", side_effect=import_without_heif): - assert _normalize_image_bytes(_png_bytes(), ".png") is None - with pytest.raises(RuntimeError, match=r"pillow-heif.*reme-ai\[image-heif\]"): - _normalize_image_bytes(_png_bytes(), ".heic") +@pytest.mark.parametrize( + ("payload", "suffix", "save_fails", "error_pattern"), + [ + (b"not an image", ".png", False, "Failed to decode image"), + (_img_bytes("BMP"), ".bmp", True, "Failed to convert/resize image"), + ], + ids=["decode", "conversion"], +) +def test_image_preprocessing_reports_data_errors(payload, suffix, save_fails, error_pattern): + """Decode and conversion failures remain explicit.""" + if not save_fails: + with pytest.raises(RuntimeError, match=error_pattern): + _build_image_request_payload(payload, suffix) + return - with pytest.raises(RuntimeError, match="Failed to decode image"): - _build_image_request_payload(b"not an image", ".png") - - bmp_data = _img_bytes("BMP") with patch("PIL.Image.Image.save", side_effect=OSError("encoder failed")): - with pytest.raises(RuntimeError, match="Failed to convert/resize image"): - _normalize_image_bytes(bmp_data, ".bmp") + with pytest.raises(RuntimeError, match=error_pattern): + _normalize_image_bytes(payload, suffix) def test_auto_image_module_imports_without_pillow(): @@ -910,65 +576,41 @@ import reme.steps.evolve.auto_image_resource assert completed.returncode == 0, completed.stderr -def test_auto_image_prompt_treats_filename_as_a_weak_hint(): +@pytest.mark.asyncio +async def test_auto_image_prompt_treats_filename_as_a_weak_hint(auto_resource_env): """The VLM prompt separates filename hints from visible image evidence.""" + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/cat-at-beach.png", _png_bytes()) + model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) - 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_binary(cwd / "resource" / "2026-01-01" / "cat-at-beach.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - prompt = model.calls[0][0].content[0].text - assert "Filename: cat-at-beach.png" in prompt - assert "Filename stem: cat-at-beach" in prompt - assert "weak hints" in prompt - assert "trust the visible image content" in prompt - finally: - await fs.close() - - asyncio.run(run()) + assert response.success is True + prompt = model.calls[0][0].content[0].text + assert "Filename: cat-at-beach.png" in prompt + assert "Filename stem: cat-at-beach" in prompt + assert "weak hints" in prompt + assert "trust the visible image content" in prompt -def test_auto_image_reports_modified_when_index_refresh_fails_after_write(): +@pytest.mark.asyncio +async def test_auto_image_reports_modified_when_index_refresh_fails_after_write(auto_resource_env): """A post-write failure keeps the actual on-disk modification state.""" + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("red-square", "Red", "A red square.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) + async def fail_refresh(*_args, **_kwargs): + raise RuntimeError("index refresh failed") - async def fail_refresh(*_args, **_kwargs): - raise RuntimeError("index refresh failed") + with patch("reme.steps.evolve.base_auto_resource.refresh_day_index", new=fail_refresh): + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) - with patch("reme.steps.evolve.base_auto_resource.refresh_day_index", new=fail_refresh): - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - result = resp.metadata["results"][0] - assert resp.success is False - assert result["metadata"]["action"] == "failed" - assert result["metadata"]["modified"] is True - assert "index refresh failed" in result["metadata"]["error"] - assert (cwd / "daily" / "2026-01-01" / "red-square.md").is_file() - finally: - await fs.close() - - asyncio.run(run()) + result = response.metadata["results"][0] + assert response.success is False + assert result["metadata"]["action"] == "failed" + assert result["metadata"]["modified"] is True + assert "index refresh failed" in result["metadata"]["error"] + assert (env.workspace / "daily/2026-01-01/red-square.md").is_file() def test_default_resource_watcher_dispatches_only_the_unified_router(): @@ -986,7 +628,7 @@ def test_default_resource_watcher_dispatches_only_the_unified_router(): auto_resource = config["jobs"]["auto_resource"]["steps"][0] assert auto_resource["backend"] == "auto_resource_step" assert auto_resource["dispatch_steps"] == ["auto_image_resource_step", "auto_text_resource_step"] - assert config["jobs"]["auto_image"]["steps"] == [{"backend": "auto_image_resource_step"}] + assert "auto_image" not in config["jobs"] def test_image_dependencies_keep_heif_support_optional(): @@ -1003,312 +645,135 @@ def test_image_dependencies_keep_heif_support_optional(): assert "reme-ai[image-heif]" in optional["full"] -def test_auto_image_model_failure_is_isolated_per_change(): - """A failing VLM call marks one change failed while the rest of the batch continues.""" +@pytest.mark.parametrize("failure_stage", ["model", "decode"]) +@pytest.mark.asyncio +async def test_auto_image_failure_is_isolated_per_change(failure_stage, auto_resource_env): + """Model and decode failures do not block the next image in a batch.""" + env = auto_resource_env + if failure_stage == "model": + first_data = _png_bytes() + model = _FlakyVisionModel(_caption_json("good-image", "Good", "A valid image.")) + expected_error = "vision backend unavailable" + else: + first_data = b"not an image" + model = _FakeVisionModel(_caption_json("good-image", "Good", "A valid image.")) + expected_error = "Failed to decode image" + first = env.write_binary("resource/2026-01-01/first.png", first_data) + second = env.write_binary("resource/2026-01-01/second.png", _png_bytes(color=(20, 90, 200))) - 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: - first = _write_binary(cwd / "resource" / "2026-01-01" / "img-a.png", _png_bytes()) - second = _write_binary(cwd / "resource" / "2026-01-01" / "img-b.png", _png_bytes(color=(20, 90, 200))) - model = _FlakyVisionModel(_caption_json("blue-square", "Blue", "A blue square.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step( - step, - [ - {"change": "added", "path": str(first)}, - {"change": "added", "path": str(second)}, - ], - ) + response = await env.run( + env.processor(model), + [ + {"change": "added", "path": str(first)}, + {"change": "added", "path": str(second)}, + ], + ) - assert resp.success is False - results = resp.metadata["results"] - assert results[0]["success"] is False - assert results[0]["metadata"]["action"] == "failed" - assert results[1]["success"] is True - assert (cwd / "daily" / "2026-01-01" / "blue-square.md").is_file() - assert first.exists() and second.exists() - finally: - await fs.close() - - asyncio.run(run()) + results = response.metadata["results"] + assert response.success is False + assert results[0]["success"] is False + assert results[0]["metadata"]["action"] == "failed" + assert expected_error in results[0]["metadata"]["error"] + assert results[1]["success"] is True + assert (env.workspace / "daily/2026-01-01/good-image.md").is_file() + assert first.exists() and second.exists() -def test_auto_image_decode_failure_is_isolated_per_change(): - """Corrupt image bytes fail one item without blocking a later valid image.""" - - 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: - corrupt = _write_binary(cwd / "resource" / "2026-01-01" / "bad.png", b"not an image") - valid = _write_binary(cwd / "resource" / "2026-01-01" / "good.png", _png_bytes()) - model = _FakeVisionModel(_caption_json("good-image", "Good", "A valid image.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step( - step, - [ - {"change": "added", "path": str(corrupt)}, - {"change": "added", "path": str(valid)}, - ], - ) - - assert resp.success is False - assert resp.metadata["results"][0]["metadata"]["action"] == "failed" - assert "Failed to decode image" in resp.metadata["results"][0]["metadata"]["error"] - assert resp.metadata["results"][1]["success"] is True - assert len(model.calls) == 1 - assert (cwd / "daily" / "2026-01-01" / "good-image.md").is_file() - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_uniquifies_conflicting_note_name(): +@pytest.mark.asyncio +async def test_auto_image_uniquifies_conflicting_note_name(auto_resource_env): """A name collision with an unrelated note falls back to the sha1-suffixed path.""" - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - _write_note(cwd / "daily" / "2026-01-01" / "red-square.md", "[[resource/2026-01-01/other.png]]") - model = _FakeVisionModel(_caption_json("red-square", "A red square", "An 8x8 solid red square.")) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + env.write_note("daily/2026-01-01/red-square.md", "[[resource/2026-01-01/other.png]]") + model = _FakeVisionModel(_caption_json("red-square", "A red square", "An 8x8 solid red square.")) + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) - assert resp.success is True - suffix = hashlib.sha1(b"resource/2026-01-01/img.png").hexdigest()[:8] - assert (cwd / "daily" / "2026-01-01" / f"red-square--{suffix}.md").is_file() - finally: - await fs.close() - - asyncio.run(run()) + assert response.success is True + suffix = hashlib.sha1(b"resource/2026-01-01/img.png").hexdigest()[:8] + assert (env.workspace / f"daily/2026-01-01/red-square--{suffix}.md").is_file() -def test_parse_caption_json_cross_fills_missing_fields(): - """A description-only JSON payload cross-fills the caption instead of leaking raw JSON.""" - parsed = _parse_caption_json('{"description": "Waterfall in Iceland."}') - assert parsed["caption"] == "Waterfall in Iceland." - assert parsed["description"] == "Waterfall in Iceland." - assert parsed["name"] == "" - - parsed = _parse_caption_json('{"caption": "A red square."}') - assert parsed["caption"] == "A red square." - assert parsed["description"] == "" - - parsed = _parse_caption_json('```json\n{"name": "n", "description": "d", "caption": "c"}\n```') - assert parsed == {"name": "n", "description": "d", "caption": "c"} +@pytest.mark.parametrize( + ("text", "expected"), + [ + ( + '{"description": "Waterfall in Iceland."}', + {"name": "", "description": "Waterfall in Iceland.", "caption": "Waterfall in Iceland."}, + ), + ('{"caption": "A red square."}', {"name": "", "description": "", "caption": "A red square."}), + ( + '```json\n{"name": "n", "description": "d", "caption": "c"}\n```', + {"name": "n", "description": "d", "caption": "c"}, + ), + ( + "A plain description without json.", + {"name": "", "description": "", "caption": "A plain description without json."}, + ), + ('{"foo": 1}', {"name": "", "description": "", "caption": ""}), + ("```json\n\n```", {"name": "", "description": "", "caption": ""}), + ], +) +def test_parse_caption_json(text, expected): + """Plain fallback parsing normalizes useful fields without leaking unusable JSON.""" + assert _parse_caption_json(text) == expected -def test_parse_caption_json_falls_back_to_raw_text(): - """Unusable payloads degrade to a raw-text caption.""" - parsed = _parse_caption_json("A plain description without json.") - assert parsed == {"name": "", "description": "", "caption": "A plain description without json."} +@pytest.mark.parametrize( + ("structured_mode", "note_name", "caption", "expected_plain_calls"), + [ + ("success", "red-square", "An 8x8 red square.", 0), + ("error", "plain-note", "Plain-call caption.", 1), + ("empty", "empty-note", "Recovered by plain call.", 1), + ], +) +@pytest.mark.asyncio +async def test_auto_image_structured_output_and_plain_retry( + structured_mode, + note_name, + caption, + expected_plain_calls, + auto_resource_env, +): + """Structured success stays primary; structured errors and empties retry plain once.""" + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/img.png", _png_bytes()) + if structured_mode == "success": + model = _StructuredVisionModel( + content={"name": note_name, "description": "A red square.", "caption": caption}, + ) + elif structured_mode == "error": + model = _StructuredVisionModel( + error=RuntimeError("provider rejects tool_choice"), + plain_text=_caption_json(note_name, "Plain", caption), + ) + else: + model = _StructuredVisionModel( + content={"name": "", "description": "", "caption": ""}, + plain_text=_caption_json(note_name, "Empty", caption), + ) - parsed = _parse_caption_json('{"foo": 1}') - assert parsed["caption"] == '{"foo": 1}' + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) + + assert response.success is True + assert len(model.structured_calls) == 1 + assert len(model.plain_calls) == expected_plain_calls + content = (env.workspace / f"daily/2026-01-01/{note_name}.md").read_text(encoding="utf-8") + assert caption in content -def test_auto_image_note_body_stays_clean_when_caption_field_missing(): - """Real-model regression: JSON with only a description must not enter the body verbatim.""" - - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _FakeVisionModel('{"file": "resource/2026-01-01/img.png", "description": "A tall waterfall."}') - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - note_path = cwd / "daily" / "2026-01-01" / "img.md" - assert note_path.is_file() - content = note_path.read_text(encoding="utf-8") - assert "A tall waterfall." in content - assert '{"file"' not in content - assert '"description"' not in content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_uses_structured_output_first(): - """The schema-forced structured call is the primary path; no plain fallback.""" - - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _StructuredVisionModel( - content={"name": "red-square", "description": "A red square.", "caption": "An 8x8 red square."}, - ) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - assert len(model.structured_calls) == 1 - assert not model.plain_calls - note_path = cwd / "daily" / "2026-01-01" / "red-square.md" - assert note_path.is_file() - content = note_path.read_text(encoding="utf-8") - assert "An 8x8 red square." in content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_retries_with_plain_call_when_structured_fails(): - """A failing structured call retries once via the plain-call path.""" - - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _StructuredVisionModel( - error=RuntimeError("provider rejects tool_choice"), - plain_text=_caption_json("plain-note", "Plain", "Plain-call caption."), - ) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - assert len(model.structured_calls) == 1 - assert len(model.plain_calls) == 1 - note_path = cwd / "daily" / "2026-01-01" / "plain-note.md" - content = note_path.read_text(encoding="utf-8") - assert "Plain-call caption." in content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_falls_back_when_structured_content_empty(): - """A structured response with no usable fields also triggers the plain retry.""" - - 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_binary(cwd / "resource" / "2026-01-01" / "img.png", _png_bytes()) - model = _StructuredVisionModel( - content={"name": "", "description": "", "caption": ""}, - plain_text=_caption_json("empty-note", "Empty", "Recovered by plain call."), - ) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) - - assert resp.success is True - assert len(model.plain_calls) == 1 - content = (cwd / "daily" / "2026-01-01" / "empty-note.md").read_text(encoding="utf-8") - assert "Recovered by plain call." in content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_converts_bmp_tiff_requests(): - """Core BMP/TIFF conversions run without the optional HEIC dependency.""" - - 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: - sources = [] - for stem, fmt, suffix in ( - ("photo", "BMP", ".bmp"), - ("scan", "TIFF", ".tiff"), - ): - sources.append(_write_binary(cwd / "resource" / "2026-01-01" / f"{stem}{suffix}", _img_bytes(fmt))) - model = _StructuredVisionModel(content={"name": "", "description": "d", "caption": "converted caption"}) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(p)} for p in sources]) - - assert resp.success is True - assert len(model.structured_calls) == 2 - sent_mimes = set() - for call in model.structured_calls: - sent_mimes.add(call[0].content[1].source.media_type) - assert sent_mimes <= {"image/png", "image/jpeg"} - for stem, expected_mime in (("photo", "image/bmp"), ("scan", "image/tiff")): - note = frontmatter.loads((cwd / "daily" / "2026-01-01" / f"{stem}.md").read_text(encoding="utf-8")) - assert note.metadata["media_type"] == expected_mime - assert "converted caption" in note.content - finally: - await fs.close() - - asyncio.run(run()) - - -def test_auto_image_converts_heic_request_when_extra_is_installed(): +@pytest.mark.asyncio +async def test_auto_image_converts_heic_request_when_extra_is_installed(auto_resource_env): """HEIC conversion is covered independently when the image-heif extra exists.""" pytest.importorskip("pillow_heif") - 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_binary( - cwd / "resource" / "2026-01-01" / "phone.heic", - _img_bytes("HEIF"), - ) - model = _StructuredVisionModel( - content={"name": "", "description": "d", "caption": "converted caption"}, - ) - step = AutoImageResourceStep(app_context=app_ctx, file_store=fs, as_llm=model) - resp = await _run_step(step, [{"change": "added", "path": str(source)}]) + env = auto_resource_env + source = env.write_binary("resource/2026-01-01/phone.heic", _img_bytes("HEIF")) + model = _StructuredVisionModel(content={"name": "", "description": "d", "caption": "converted caption"}) + response = await env.run(env.processor(model), [{"change": "added", "path": str(source)}]) - assert resp.success is True - assert model.structured_calls[0][0].content[1].source.media_type in {"image/png", "image/jpeg"} - note = frontmatter.loads((cwd / "daily" / "2026-01-01" / "phone.md").read_text(encoding="utf-8")) - assert note.metadata["media_type"] == "image/heic" - assert "converted caption" in note.content - finally: - await fs.close() - - asyncio.run(run()) + assert response.success is True + assert model.structured_calls[0][0].content[1].source.media_type in {"image/png", "image/jpeg"} + note = frontmatter.loads((env.workspace / "daily/2026-01-01/phone.md").read_text(encoding="utf-8")) + assert note.metadata["media_type"] == "image/heic" + assert "converted caption" in note.content diff --git a/tests/unit/test_auto_resource_review_regressions.py b/tests/unit/test_auto_resource_review_regressions.py new file mode 100644 index 00000000..e4dc307c --- /dev/null +++ b/tests/unit/test_auto_resource_review_regressions.py @@ -0,0 +1,168 @@ +"""Regression tests for the safety findings from the Auto Resource PR review.""" + +import frontmatter +import pytest + +from .auto_resource_test_support import ( + FakeVisionModel, + StructuredVisionModel, + caption_json, + image_bytes, + write_binary, + write_note, +) + +pytestmark = pytest.mark.asyncio + + +@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"]) +async def test_image_rejects_paths_outside_the_resource_tree(routed, auto_resource_env, tmp_path): + """Traversal and external paths fail before image reads.""" + env = auto_resource_env + outside = write_binary(tmp_path / "outside.png", image_bytes()) + nonresource = env.write_binary("private.png", image_bytes()) + + model = FakeVisionModel(caption_json("unsafe", "Unsafe", "Must not be read.")) + response = await env.run( + env.processor(model, routed=routed), + [ + {"change": "added", "path": "resource/2026-01-01/../../../outside.png"}, + {"change": "added", "path": str(outside)}, + {"change": "added", "path": str(nonresource)}, + ], + ) + + results = response.metadata["results"] + assert response.success is False + assert len(results) == 3 + assert all(item["metadata"]["action"] == "failed" for item in results) + assert all(item["metadata"]["modified"] is False for item in results) + assert "cannot contain '.' or '..'" in results[0]["metadata"]["error"] + assert "must stay inside the workspace" in results[1]["metadata"]["error"] + assert "configured resource directory" in results[2]["metadata"]["error"] + assert not model.calls + assert outside.read_bytes() == image_bytes() + + +@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"]) +async def test_image_rejects_resource_symlink_outside_workspace(routed, auto_resource_env, tmp_path): + """An escaping resource symlink fails without weakening other containment tests.""" + env = auto_resource_env + outside = write_binary(tmp_path / "outside.png", image_bytes()) + external_link = env.workspace / "resource/2026-01-01/external.png" + external_link.parent.mkdir(parents=True, exist_ok=True) + try: + external_link.symlink_to(outside) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + model = FakeVisionModel(caption_json("unsafe", "Unsafe", "Must not be read.")) + response = await env.run( + env.processor(model, routed=routed), + [{"change": "added", "path": str(external_link)}], + ) + + result = response.metadata["results"][0] + assert response.success is False + assert result["metadata"]["action"] == "failed" + assert result["metadata"]["modified"] is False + assert "must stay inside the workspace" in result["metadata"]["error"] + assert not model.calls + assert outside.read_bytes() == image_bytes() + + +@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"]) +async def test_image_internal_symlink_keeps_logical_provenance(routed, auto_resource_env): + """An internal symlink is read safely while ownership follows the watched alias.""" + env = auto_resource_env + target = env.write_binary("resource/2026-01-01/original.png", image_bytes()) + link = target.with_name("alias.png") + try: + link.symlink_to(target.name) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + model = FakeVisionModel(caption_json("linked-image", "Linked", "An internal linked image.")) + step = env.processor(model, routed=routed) + response = await env.run(step, [{"change": "added", "path": str(link)}]) + + note_path = env.workspace / "daily/2026-01-01/linked-image.md" + post = frontmatter.loads(note_path.read_text(encoding="utf-8")) + assert response.success is True + assert post.metadata["source_resource"] == "[[resource/2026-01-01/alias.png]]" + assert "![[resource/2026-01-01/alias.png]]" in post.content + assert len(model.calls) == 1 + + link.unlink() + deleted = await env.run(step, [{"change": "deleted", "path": str(link)}]) + assert deleted.success is True + assert deleted.metadata["results"][0]["metadata"]["action"] == "deleted" + assert not note_path.exists() + assert target.is_file() + + +@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"]) +@pytest.mark.parametrize("existing_owner", ["different-source", "no-source"]) +@pytest.mark.parametrize("change", ["modified", "deleted"]) +async def test_image_preserves_unowned_same_stem_note(routed, existing_owner, change, auto_resource_env): + """Upsert and delete never claim a same-stem note without exact ownership.""" + env = auto_resource_env + source = env.workspace / "resource/2026-01-01/img.png" + if change == "modified": + write_binary(source, image_bytes()) + same_stem = env.workspace / "daily/2026-01-01/img.md" + if existing_owner == "different-source": + write_note(same_stem, "[[resource/2026-01-01/other.png]]", body="unrelated image note") + else: + same_stem.parent.mkdir(parents=True, exist_ok=True) + same_stem.write_text( + "---\nname: img\ndescription: user-owned note\n---\nuser-owned bytes\n", + encoding="utf-8", + ) + before = same_stem.read_bytes() + + model = FakeVisionModel(caption_json("generated-caption", "Generated", "Generated caption.")) + response = await env.run(env.processor(model, routed=routed), [{"change": change, "path": str(source)}]) + + assert response.success is True + assert same_stem.read_bytes() == before + if change == "deleted": + result = response.metadata["results"][0]["metadata"] + assert result["action"] == "skipped" + assert result["reason"] == "resource_note_not_found" + assert result["modified"] is False + else: + generated = env.workspace / "daily/2026-01-01/generated-caption.md" + post = frontmatter.loads(generated.read_text(encoding="utf-8")) + assert post.metadata["source_resource"] == "[[resource/2026-01-01/img.png]]" + assert "Generated caption." in post.content + + +@pytest.mark.parametrize("routed", [False, True], ids=["image", "unified-router"]) +@pytest.mark.parametrize("plain_text", [" ", "```json\n\n```"], ids=["whitespace", "empty-json-fence"]) +async def test_blank_plain_caption_does_not_create_or_overwrite_note(routed, plain_text, auto_resource_env): + """An empty structured result plus blank plain fallback leaves notes untouched.""" + env = auto_resource_env + new_source = env.write_binary("resource/2026-01-01/blank-new.png", image_bytes()) + old_source = env.write_binary("resource/2026-01-01/blank-old.png", image_bytes()) + old_note = env.write_note( + "daily/2026-01-01/preserved.md", + "[[resource/2026-01-01/blank-old.png]]", + body="caption that must survive", + ) + before = old_note.read_bytes() + model = StructuredVisionModel(content={}, plain_text=plain_text) + step = env.processor(model, routed=routed) + + added = await env.run(step, [{"change": "added", "path": str(new_source)}]) + modified = await env.run(step, [{"change": "modified", "path": str(old_source)}]) + + for response in (added, modified): + result = response.metadata["results"][0]["metadata"] + assert response.success is False + assert result["action"] == "failed" + assert result["modified"] is False + assert "no usable caption" in result["error"] + assert not (env.workspace / "daily/2026-01-01/blank-new.md").exists() + assert old_note.read_bytes() == before + assert len(model.structured_calls) == len(model.plain_calls) == 2 diff --git a/tests/unit/test_background_steps.py b/tests/unit/test_background_steps.py index 33a3f93e..4ac42542 100644 --- a/tests/unit/test_background_steps.py +++ b/tests/unit/test_background_steps.py @@ -189,6 +189,14 @@ def test_match_file_suffix(): print("✓ test_match_file_suffix passed") +def test_match_file_suffix_is_case_insensitive(): + """Configured suffixes match uppercase file extensions.""" + rules = [WatchRule(path=Path("/workspace/resource"), suffixes=["jpg", ".png"])] + assert match_file("/workspace/resource/photo.JPG", rules) + assert match_file("/workspace/resource/sub/diagram.PNG", rules) + assert not match_file("/workspace/resource/photo.GIF", rules) + + def test_match_file_no_suffix_filter(): """Empty suffixes list means all files match.""" rules = [WatchRule(path=Path("/workspace/resource"), suffixes=[])] @@ -222,6 +230,18 @@ def test_collect_existing_filters(): print("✓ test_collect_existing_filters passed") +def test_collect_existing_matches_uppercase_suffixes(): + """The initial scan includes files whose extension casing differs from the rule.""" + with tempfile.TemporaryDirectory() as tmpdir: + resource = Path(tmpdir) / "resource" + uppercase = write_file(resource / "photo.JPG") + write_file(resource / "ignore.GIF") + + result = collect_existing([WatchRule(path=resource, suffixes=["jpg"])], recursive=True) + + assert set(result) == {str(uppercase.absolute())} + + # --------------------------------------------------------------------------- # InitChangesStep # --------------------------------------------------------------------------- @@ -1173,6 +1193,21 @@ def test_watch_changes_filter_matches_rules(): 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.""" + with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + (workspace / "resource").mkdir() + app_ctx = _make_app_context(workspace) + + step = WatchChangesStep(app_context=app_ctx) + step.context = RuntimeContext(watch_dirs=["resource_dir"], watch_suffixes=["jpg"]) + step._rules = step._get_watch_rules() + + assert step._filter(Change.added, str(workspace / "resource/photo.JPG")) + assert not step._filter(Change.added, str(workspace / "resource/photo.PNG")) + + def test_watch_changes_dispatch_steps_list(): """dispatch_steps config is stored by BaseStep.""" step = WatchChangesStep(dispatch_steps=["update_catalog_step", "auto_resource_step"]) @@ -1337,6 +1372,155 @@ def test_auto_resource_handles_file_removed_before_stat(): 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) + + 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"}, + ], + ), + ) + + 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") + + asyncio.run(run()) + + +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)) + + 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)}])) + + 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()) + + +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 = {} + + 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)}])) + + 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()) + + def test_auto_resource_accepts_loose_root_resource(): """Root-level resource files use today's date without moving the source.""" @@ -1350,9 +1534,15 @@ def test_auto_resource_accepts_loose_root_resource(): step = AutoTextResourceStep(app_context=app_ctx) - async def fake_upsert(file_path, date_str, note_stem, created): + 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}, + { + "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" @@ -1369,6 +1559,7 @@ def test_auto_resource_accepts_loose_root_resource(): "date_str": today, "note_stem": "report", "created": True, + "source_path": source.resolve(), } print("✓ test_auto_resource_accepts_loose_root_resource passed") @@ -1389,9 +1580,15 @@ def test_auto_resource_loose_root_resource_keeps_existing_dated_resource(): step = AutoTextResourceStep(app_context=app_ctx) - async def fake_upsert(file_path, date_str, note_stem, created): + 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}, + { + "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" @@ -1408,6 +1605,7 @@ def test_auto_resource_loose_root_resource_keeps_existing_dated_resource(): "date_str": today, "note_stem": "report", "created": True, + "source_path": source.resolve(), } print("✓ test_auto_resource_loose_root_resource_keeps_existing_dated_resource passed") @@ -1451,6 +1649,98 @@ def test_auto_resource_modified_missing_note_uses_create_tools(): asyncio.run(run()) +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", + ) + 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)}])) + + 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()) + + +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 + + 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)}])) + + 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()) + + def test_auto_resource_sanitizes_invalid_generated_name(): """Invalid LLM-suggested names are sanitized before renaming.""" @@ -1637,8 +1927,8 @@ def test_auto_resource_reports_unmodified_when_agent_skips_existing_note(): asyncio.run(run()) -def test_auto_resource_deletes_loose_root_resource_note_for_today(): - """Deleting a loose root resource deletes today's same-stem note.""" +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): @@ -1656,12 +1946,15 @@ def test_auto_resource_deletes_loose_root_resource_note_for_today(): assert resp.success is True assert resp.metadata["results"][0]["path"] == "resource/report.txt" - assert resp.metadata["results"][0]["metadata"]["modified"] is True - assert resp.metadata["modified"] is True - assert not note_path.exists() + 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_deletes_loose_root_resource_note_for_today passed") + print("✓ test_auto_resource_preserves_unowned_loose_root_same_stem_note passed") asyncio.run(run()) @@ -1939,7 +2232,7 @@ if __name__ == "__main__": 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_deletes_loose_root_resource_note_for_today() + 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