ReMe/tests/unit/test_auto_resource_agent_inputs.py
WQS ce386f5391
feat(auto-resource): unify text and image auto-resource using agentwrapper (#555)
* feat(auto_resource): unify text and image agent workflows

* test(auto_resource): streamline coverage and clarify image prompts

* refactor(auto_resource): simplify shared agent interpretation

* test(codex): isolate stdio startup budgets and teardown

* refactor(auto-resource): align image options and wrapper backend checks

* fix(auto-resource): finalize image notes after agent errors

* fix(auto-resource): complete image agent review fixes

* refactor(auto-resource): simplify shared reply failure finalization

* refactor(auto-resource): compose shared resource instructions

* fix(auto-resource): preserve prompt configuration compatibility

* refactor(auto-resource): remove legacy image prompt aliases
2026-09-23 10:20:35 +08:00

600 lines
28 KiB
Python

"""Native AgentScope input checks and image-agent lifecycle regressions (no network)."""
# pylint: disable=protected-access
import asyncio
import uuid
from pathlib import Path
from unittest.mock import AsyncMock, patch
import frontmatter
import pytest
import yaml
from agentscope.message import DataBlock, Msg, TextBlock
from reme.application import Application
from reme.components import ApplicationContext, R
from reme.components.agent_wrapper import AsAgentWrapper
from reme.components.runtime_context import RuntimeContext
from reme.enumeration import ComponentEnum
from reme.steps.evolve.auto_image_resource import AutoImageResourceStep
from reme.steps.evolve.auto_resource import AutoResourceStep
from reme.steps.evolve.auto_text_resource import AutoTextResourceStep
from .auto_resource_test_support import FakeAgentWrapper, FakeImageAgentWrapper, caption_fields, image_bytes
pytest_plugins = ("unit.auto_resource_test_plugin",)
pytestmark = pytest.mark.asyncio
@pytest.mark.parametrize("existing", [False, True], ids=["create", "update"])
@pytest.mark.parametrize(
"options",
[{}, {"include_images": True}, {"include_images": False}, {"include_images": "false"}, {"include_images": None}],
)
async def test_text_agent_keeps_main_reply_arguments_and_wrapper_defaults(existing, options, auto_resource_env):
"""Sharing interpretation must not override the text wrapper's optional settings."""
env = auto_resource_env
source_path = "resource/2026-01-01/notes.txt"
source = env.write_binary(source_path, "中文文本".encode())
if existing:
env.write_note("daily/2026-01-01/notes.md", f"[[{source_path}]]")
wrapper = FakeAgentWrapper()
step = AutoTextResourceStep(app_context=env.app_context, file_store=env.file_store, agent_wrapper=wrapper)
with patch.object(wrapper, "reply", new=AsyncMock(wraps=wrapper.reply)) as reply:
response = await env.run(
step,
[{"change": "modified" if existing else "added", "path": str(source)}],
**options,
)
assert response.success
assert response.answer == "ok"
assert isinstance(wrapper.inputs, str)
assert "中文文本" in wrapper.inputs
reply.assert_awaited_once()
assert reply.call_args.kwargs == {
"system_prompt": step.prompt_format("system_prompt"),
"job_tools": ["read", "edit", "frontmatter_update", "write"] if existing else ["write"],
"session_id": str(uuid.uuid5(uuid.NAMESPACE_URL, source_path)),
}
tools = step.update_tools if existing else step.create_tools
tools.append("custom_note_tool")
with patch.object(wrapper, "reply", new=AsyncMock(wraps=wrapper.reply)) as reply:
await env.run(step, [{"change": "modified", "path": str(source)}], **options)
assert reply.call_args.kwargs["job_tools"] == tools
assert reply.call_args.kwargs["session_id"] == str(uuid.uuid5(uuid.NAMESPACE_URL, source_path))
async def test_native_image_input_preserves_context_formatter_and_scoped_tools(auto_resource_env):
"""Exercise real Agent construction/observe/format and real file tools, but no model call."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/brown-coat.png", image_bytes())
wrapper = FakeImageAgentWrapper(caption_fields("coat", "Brown coat", "A brown coat."))
response = await env.run(env.processor(wrapper), [{"change": "added", "path": str(source)}])
assert response.success
message, options = wrapper.calls[0]
assert isinstance(message, Msg)
assert sum(isinstance(block, DataBlock) for block in message.content) == 1
assert options["job_tools"] == ["write"]
assert options["builtin_tools"] == []
assert options["skills"] == []
assert options["toolkit"] is None
assert options["output_schema"] is None
assert options["resume"] is None
assert options["session_id"] is None
assert "scope_note_tools" not in options
assert options["injected_job_kwargs"]["_allowed_paths"] == ["daily/2026-01-01/brown-coat.md"]
native = AsAgentWrapper(
app_context=ApplicationContext(workspace_dir=str(env.workspace)),
as_llm="",
session_retention_days=0,
session_id=str(uuid.uuid5(uuid.NAMESPACE_URL, "component-default-session")),
resume=str(uuid.uuid5(uuid.NAMESPACE_URL, "component-default-resume")),
)
native.as_llm = wrapper.as_llm
# Reuse actual file-job adapters; only model inference is intentionally absent.
native.app_context.jobs = env.app_context.jobs
merged_options = native._merged_kwargs(options)
assert merged_options["session_id"] is merged_options["resume"] is None
agent, forwarded = await native._build_agent(message, **merged_options)
assert forwarded is message
assert agent.model is wrapper.as_llm.model
await agent.observe(forwarded)
await agent._limit_context_images(agent.context_config)
formatted = await agent.model.formatter.format(agent.state.context)
sent_image = next(block for block in formatted[0]["content"] if block["type"] == "image_url")
original_image = next(block for block in message.content if isinstance(block, DataBlock))
assert sent_image["image_url"]["url"] == (
f"data:{original_image.source.media_type};base64,{original_image.source.data}"
)
restored = Msg.model_validate_json(agent.state.context[0].model_dump_json())
assert next(block for block in restored.content if isinstance(block, DataBlock)) == original_image
new_agent, _ = await native._build_agent(message, **merged_options)
assert agent.state.session_id != new_agent.state.session_id
assert agent.state.session_id != native.kwargs["session_id"]
assert new_agent.state.session_id != native.kwargs["session_id"]
assert not new_agent.state.context
assert not (env.workspace / "mem_session").exists()
outside = env.workspace / "unrelated.md"
outside.write_text("preserve", encoding="utf-8")
tool = native._make_tool(
env.app_context.jobs["write"],
injected_job_kwargs=options["injected_job_kwargs"],
)
result = await tool.call(path="unrelated.md", content="must not overwrite")
assert "error" in str(result.state).lower()
assert outside.read_text(encoding="utf-8") == "preserve"
async def test_message_blocks_do_not_implicitly_enable_image_policies(auto_resource_env):
"""Input shape alone must not select image tool scoping or session policy."""
env = auto_resource_env
source_path = "resource/2026-01-01/notes.txt"
wrapper = FakeAgentWrapper()
step = AutoTextResourceStep(app_context=env.app_context, file_store=env.file_store, agent_wrapper=wrapper)
step.context = RuntimeContext()
with patch.object(wrapper, "reply", new=AsyncMock(wraps=wrapper.reply)) as reply:
await step._interpret_resource(
source_path,
"2026-01-01",
"notes",
True,
"中文文本",
input_blocks=[TextBlock(text="Additional context")],
)
assert isinstance(wrapper.inputs, Msg)
assert reply.call_args.kwargs == {
"system_prompt": step.prompt_format("system_prompt"),
"job_tools": ["write"],
"session_id": str(uuid.uuid5(uuid.NAMESPACE_URL, source_path)),
}
@pytest.mark.parametrize("routed", [False, True])
@pytest.mark.parametrize("change", ["added", "modified", "deleted"])
async def test_include_images_false_skips_every_event_without_read_or_mutation(routed, change, auto_resource_env):
"""The opt-out is a full image lifecycle opt-out, including existing-note deletion."""
env = auto_resource_env
source = env.write_binary("resource/photo.png", image_bytes())
old_note = env.write_note("daily/2026-01-01/old.md", "[[resource/photo.png]]")
before = old_note.read_bytes()
wrapper = FakeImageAgentWrapper("must not run")
step = env.processor(wrapper, routed=routed)
with patch.object(AutoImageResourceStep, "_read_image", side_effect=AssertionError("must not read")):
response = await env.run(step, [{"change": change, "path": str(source)}], include_images=False)
result = response.metadata["results"][0]
assert response.success
assert result["metadata"]["action"] == "skipped"
assert result["metadata"]["reason"] == "include_images=false"
assert result["metadata"]["modified"] is False
assert not wrapper.calls
assert old_note.read_bytes() == before
assert not (env.workspace / "daily/2026-01-01.md").exists()
@pytest.mark.parametrize("routed", [False, True])
async def test_image_opt_out_does_not_bypass_resource_path_validation(routed, auto_resource_env):
"""Disabled image processing still rejects malformed external source paths."""
env = auto_resource_env
wrapper = FakeImageAgentWrapper("must not run")
response = await env.run(
env.processor(wrapper, routed=routed),
[{"change": "added", "path": "resource/../private.png"}],
include_images=False,
)
assert not response.success
assert response.metadata["results"][0]["metadata"]["action"] == "failed"
assert not wrapper.calls
async def test_disabled_images_keep_mixed_router_results_and_text_processing(auto_resource_env):
"""Routing keeps image ownership instead of handing image bytes to the text fallback."""
env = auto_resource_env
image = env.write_binary("resource/2026-01-01/photo.png", image_bytes())
text = env.write_binary("resource/2026-01-01/notes.txt", "中文文本".encode())
wrapper = FakeImageAgentWrapper("must not run")
wrapper.app_context = env.app_context
text_wrapper = FakeAgentWrapper()
env.app_context.registry = R
hook_calls = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hook_calls.append(kwargs)}
step = AutoResourceStep(
app_context=env.app_context,
file_store=env.file_store,
dispatch_steps=[
{"backend": "auto_image_resource_step", "agent_wrapper": wrapper},
{"backend": "auto_text_resource_step", "agent_wrapper": text_wrapper},
],
)
response = await env.run(
step,
[
{"change": "added", "path": str(image)},
{"change": "added", "path": str(text)},
],
include_images=False,
)
assert response.success
assert len(response.metadata["results"]) == 2
assert response.metadata["results"][0]["metadata"]["reason"] == "include_images=false"
assert "中文文本" in text_wrapper.inputs
assert not wrapper.calls
assert response.metadata["modified"] is False
assert not hook_calls
@pytest.mark.parametrize("existing", [False, True], ids=["create", "update"])
async def test_image_agent_failure_before_write_is_not_retried(existing, auto_resource_env):
"""A failure before writing must not create, repair, index, or retry a note."""
env = auto_resource_env
source_path = "resource/2026-01-01/photo.png"
source = env.write_binary(source_path, image_bytes())
note = env.workspace / "daily/2026-01-01/photo.md"
if existing:
env.write_note("daily/2026-01-01/photo.md", f"[[{source_path}]]")
before = note.read_bytes() if existing else None
hooks = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hooks.append(kwargs)}
wrapper = FakeImageAgentWrapper(error=RuntimeError("agent failed before writing"))
response = await env.run(
env.processor(wrapper, routed=True),
[{"change": "modified" if existing else "added", "path": str(source)}],
)
result = response.metadata["results"][0]["metadata"]
assert not response.success
assert len(wrapper.calls) == 1
assert result["action"] == "failed"
assert result["modified"] is False
assert (note.read_bytes() if note.exists() else None) == before
assert not hooks
assert not (env.workspace / "daily/2026-01-01.md").exists()
async def _cancel_after(task, ready):
"""Cancel the real invocation at an observed side-effect boundary and always join it."""
try:
await asyncio.wait_for(ready.wait(), timeout=5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@pytest.mark.parametrize("suffix", ["png", "txt"], ids=["image", "text"])
@pytest.mark.parametrize("existing", [False, True], ids=["create", "update"])
@pytest.mark.parametrize("cancelled", [False, True], ids=["error", "cancel"])
async def test_written_resource_is_finalized_after_reply_failure(suffix, existing, cancelled, auto_resource_env):
"""Both processors finalize real writes while preserving the reply failure or task cancellation."""
env = auto_resource_env
source_path = f"resource/2026-01-01/photo.{suffix}"
source = env.write_binary(source_path, image_bytes() if suffix == "png" else "中文文本".encode())
note_path = "daily/2026-01-01/existing-card.md" if existing else "daily/2026-01-01/new-caption.md"
target = note_path if existing else "daily/2026-01-01/photo.md"
before = None
if existing:
before = env.write_note(note_path, f"[[{source_path}]]").read_bytes()
hooks = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hooks.append(kwargs)}
wrapper = FakeImageAgentWrapper()
wrapper.app_context = env.app_context
env.app_context.registry = R
step = AutoResourceStep(
app_context=env.app_context,
dispatch_steps=[
{
"backend": "auto_image_resource_step" if suffix == "png" else "auto_text_resource_step",
"file_store": env.file_store,
"agent_wrapper": wrapper,
},
],
)
written = asyncio.Event()
async def write_then_fail(_inputs, **kwargs):
tool = wrapper._make_tool(
env.app_context.jobs["write"],
injected_job_kwargs=kwargs.get("injected_job_kwargs"),
)
await tool.call(
path=target,
name="new-caption",
description="New description",
content=f"![[{source_path}]]\n\n## Caption\n\nNew caption." if suffix == "png" else "New text.",
metadata={"source_resource": f"[[{source_path}]]"},
)
if cancelled:
written.set()
await asyncio.Event().wait()
raise RuntimeError("agent failed after writing")
with patch.object(wrapper, "reply", new=AsyncMock(side_effect=write_then_fail)) as reply:
invocation = env.run(step, [{"change": "modified" if existing else "added", "path": str(source)}])
if cancelled:
await _cancel_after(asyncio.create_task(invocation), written)
response = step.context.response
result = response.metadata
else:
response = await invocation
result = response.metadata["results"][0]["metadata"]
assert result["action"] == "failed"
assert result["error"] == "agent failed after writing"
assert len(hooks) == 1
reply.assert_awaited_once()
assert not response.success
assert result["path"] == note_path
assert response.metadata["modified"] is result["modified"] is True
note = env.workspace / note_path
assert note.read_bytes() != before
post = frontmatter.load(note)
assert post["source_resource"] == f"[[{source_path}]]"
if suffix == "png":
assert post["kind"] == "image" and post["media_type"] == "image/png"
assert post["name"] == Path(note_path).stem
assert post["description"] == "New description"
assert post.content.endswith("New caption." if suffix == "png" else "New text.")
assert not (env.workspace / "daily/2026-01-01/photo.md").exists()
index = (env.workspace / "daily/2026-01-01.md").read_text(encoding="utf-8")
assert note_path in index and "New description" in index
@pytest.mark.parametrize("failure", ["index", "caption"])
async def test_agent_error_survives_failed_post_write_finalization(failure, auto_resource_env, monkeypatch):
"""Neither an index error nor invalid output may replace the original reply error."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/photo.png", image_bytes())
wrapper = FakeImageAgentWrapper(
caption_fields("photo", "New description", "New caption." if failure == "index" else ""),
)
wrapper.note_metadata = {"source_resource": "[[resource/2026-01-01/photo.png]]"}
wrapper.after_write_error = RuntimeError("agent failed after writing")
hooks = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hooks.append(kwargs)}
if failure == "index":
monkeypatch.setattr(
"reme.steps.evolve.base_auto_resource.refresh_day_index",
AsyncMock(side_effect=RuntimeError("index refresh failed")),
)
response = await env.run(env.processor(wrapper, routed=True), [{"change": "added", "path": str(source)}])
result = response.metadata["results"][0]["metadata"]
assert not response.success
assert result["action"] == "failed"
assert result["error"] == "agent failed after writing"
assert "agent failed after writing" in response.answer
assert response.metadata["modified"] is result["modified"] is True
assert result["path"] == "daily/2026-01-01/photo.md"
assert len(wrapper.calls) == len(hooks) == 1
note = frontmatter.load(env.workspace / result["path"])
assert note["kind"] == "image" and note["media_type"] == "image/png"
assert (env.workspace / "daily/2026-01-01.md").exists() is (failure != "index")
async def test_unchanged_image_write_does_not_trigger_failure_recovery(auto_resource_env):
"""An identical rewrite is not grounds to repair an existing note or emit a hook."""
env = auto_resource_env
source_path = "resource/2026-01-01/photo.png"
source = env.write_binary(source_path, image_bytes())
wrapper = FakeImageAgentWrapper(caption_fields("photo", "Same description", "Same caption."))
wrapper.note_metadata = {"source_resource": f"[[{source_path}]]"}
note_path = "daily/2026-01-01/photo.md"
await env.app_context.jobs["write"](
path=note_path,
name="photo",
description="Same description",
content=f"![[{source_path}]]\n\n## Caption\n\nSame caption.\n",
metadata=wrapper.note_metadata,
)
note = env.workspace / note_path
before = note.read_bytes()
hooks = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hooks.append(kwargs)}
wrapper.after_write_error = RuntimeError("agent failed after identical write")
response = await env.run(env.processor(wrapper, routed=True), [{"change": "modified", "path": str(source)}])
assert not response.success
assert response.metadata["modified"] is False
assert note.read_bytes() == before
assert not hooks
assert len(wrapper.calls) == 1
assert not (env.workspace / "daily/2026-01-01.md").exists()
@pytest.mark.parametrize(
("body", "valid"),
[
("![[{source}]]\n\n## Caption\n\n一件棕色外衣。", True),
('![[{source}]]\n\n## Caption\n\nScreenshot of JSON:\n```json\n{{"count": 2}}\n```', True),
("![[{source}]]\n\n## Caption\n\n42", True),
("## Caption\n\nA coat.", False),
("![[resource/other.png]]\n\n## Caption\n\nA coat.", False),
("![[{source}]]\n\nA coat.", False),
("![[{source}]]\n\n## Caption\n\n ", False),
('![[{source}]]\n\n## Caption\n\n{{"caption": "A coat."}}', False),
('![[{source}]]\n\n## Caption\n\n["A coat."]', False),
('![[{source}]]\n\n## Caption\n\n```json\n{{"caption": "A coat."}}\n```', False),
],
ids=["text", "json-ocr", "number", "no-embed", "wrong-embed", "no-heading", "empty", "object", "array", "fence"],
)
async def test_image_note_body_is_validated_without_rolling_back_the_write(body, valid, auto_resource_env):
"""Validate actual Markdown, retaining already-written files and reporting their side effects."""
env = auto_resource_env
source_path = "resource/2026-01-01/photo.png"
source = env.write_binary(source_path, image_bytes())
wrapper = FakeImageAgentWrapper(caption_fields("photo", "Photo", "ignored by body override"))
wrapper.note_body = body.format(source=source_path)
wrapper.note_metadata = {"source_resource": f"[[{source_path}]]"}
hooks = []
env.app_context.metadata = {"qwenpaw_memory_result_hook": lambda **kwargs: hooks.append(kwargs)}
response = await env.run(env.processor(wrapper, routed=True), [{"change": "added", "path": str(source)}])
result = response.metadata["results"][0]["metadata"]
assert response.success is valid
assert result["modified"] is True
assert result["action"] == ("added" if valid else "failed")
assert len(wrapper.calls) == len(hooks) == 1
note = frontmatter.load(env.workspace / result["path"])
assert note.content == wrapper.note_body.strip()
assert note["source_resource"] == f"[[{source_path}]]"
assert note["kind"] == "image"
assert note["media_type"] == "image/png"
assert (env.workspace / "daily/2026-01-01.md").exists()
@pytest.mark.parametrize(
("before", "after", "valid"),
[
({}, {}, True),
({}, {"status": "done"}, False),
({"status": "queued"}, {"status": "queued"}, True),
({"status": "queued"}, {"status": "done"}, False),
({"status": "queued"}, {}, False),
({"status": None}, {}, False),
],
ids=["absent", "added", "preserved", "changed", "removed", "null-removed"],
)
async def test_image_agent_must_preserve_downstream_status(before, after, valid, auto_resource_env):
"""Presence and value matter; an unchanged downstream status is not an agent violation."""
env = auto_resource_env
source_path = "resource/2026-01-01/photo.png"
source = env.write_binary(source_path, image_bytes())
note_path = "daily/2026-01-01/photo.md"
env.write_note(note_path, f"[[{source_path}]]")
if before:
await env.app_context.jobs["frontmatter_update"](path=note_path, metadata=before)
wrapper = FakeImageAgentWrapper(caption_fields("photo", "Updated photo", "A brown coat."))
wrapper.note_metadata = {"source_resource": f"[[{source_path}]]", **after}
response = await env.run(env.processor(wrapper), [{"change": "modified", "path": str(source)}])
result = response.metadata["results"][0]["metadata"]
assert response.success is valid
assert result["modified"] is True
if not valid:
assert "status" in result["error"].lower()
post = frontmatter.load(env.workspace / note_path)
assert ("status" in post) is ("status" in after)
assert post.get("status") == after.get("status")
assert (env.workspace / "daily/2026-01-01.md").exists()
async def test_image_rejects_zero_image_budget(auto_resource_env):
"""A zero image budget must not yield filename-only memory."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/photo.png", image_bytes())
wrapper = FakeImageAgentWrapper()
wrapper.kwargs["context_config"] = {"max_image_num": 0}
step = AutoImageResourceStep(app_context=env.app_context, file_store=env.file_store, agent_wrapper=wrapper)
response = await env.run(step, [{"change": "added", "path": str(source)}])
assert not response.success
assert "context_config.max_image_num" in response.metadata["results"][0]["metadata"]["error"]
assert response.metadata["results"][0]["metadata"]["modified"] is False
assert not wrapper.calls
assert not (env.workspace / "daily/2026-01-01/photo.md").exists()
@pytest.mark.parametrize(
("backend", "suffix", "job_options", "call_options", "expected_action"),
[
("agentscope", "png", {}, {}, "added"),
("agentscope", "png", {"include_images": False}, {}, "skipped"),
("agentscope", "png", {"include_images": True}, {"include_images": False}, "skipped"),
("agentscope", "png", {"include_images": False}, {"include_images": True}, "added"),
("claude_code", "png", {}, {}, "failed"),
("claude_code", "png", {}, {"include_images": False}, "skipped"),
("claude_code", "txt", {}, {"include_images": True}, None),
],
ids=["default", "job-disables", "call-disables", "call-enables", "other-backend", "disabled", "text"],
)
async def test_configured_wrapper_and_job_image_options(
backend,
suffix,
job_options,
call_options,
expected_action,
tmp_path,
):
"""Use registry-built wrappers and real jobs; fake only the external agent reply."""
root = Path(__file__).resolve().parents[2]
defaults = yaml.safe_load((root / "reme/config/default.yaml").read_text(encoding="utf-8"))
jobs = {
name: defaults["jobs"][name] for name in ("auto_resource", "write", "move", "frontmatter_update", "daily_list")
}
jobs["auto_resource"].update(job_options)
jobs["auto_resource"]["steps"][0]["agent_wrapper"] = "resource_agent"
app = Application(
workspace_dir=str(tmp_path),
enable_logo=False,
log_to_console=False,
log_to_file=False,
service={"backend": "cli"},
components={
"agent_wrapper": {"resource_agent": {"backend": backend, "as_llm": ""}},
"file_store": {"default": {"backend": "local", "embedding_store": ""}},
"file_graph": {"default": {"backend": "local"}},
"keyword_index": {"default": {"backend": "bm25"}},
"tokenizer": {"default": {"backend": "regex"}},
},
jobs=jobs,
)
source = tmp_path / f"resource/2026-01-01/photo.{suffix}"
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(image_bytes() if suffix == "png" else "中文文本".encode())
wrapper = app.context.components[ComponentEnum.AGENT_WRAPPER]["resource_agent"]
assert wrapper.backend == backend
assert type(wrapper) is app.context.registry.get(ComponentEnum.AGENT_WRAPPER, backend)
fake = (
FakeImageAgentWrapper(caption_fields("photo", "Photo", "An image.")) if suffix == "png" else FakeAgentWrapper()
)
fake.app_context = app.context
await app.start()
try:
with patch.object(wrapper, "reply", new=AsyncMock(side_effect=fake.reply)) as reply:
response = await app.run_job(
"auto_resource",
changes=[{"change": "added", "path": str(source)}],
**call_options,
)
finally:
await app.close()
result = response.metadata["results"][0]["metadata"]
assert response.success is (expected_action != "failed")
assert result.get("action") == expected_action
assert reply.await_count == int(expected_action == "added" or suffix == "txt")
if suffix == "txt":
assert response.answer == "ok"
assert isinstance(reply.call_args.args[0], str)
assert set(reply.call_args.kwargs) == {"system_prompt", "job_tools", "session_id"}
elif expected_action == "added":
note = frontmatter.load(tmp_path / result["path"])
assert note["source_resource"] == f"[[resource/2026-01-01/photo.{suffix}]]"
elif expected_action == "skipped":
assert result["reason"] == "include_images=false"
else:
assert "AgentScope wrapper" in result["error"]
@pytest.mark.parametrize("owner", [None, "[[resource/other.png]]"], ids=["missing-owner", "foreign-owner"])
async def test_partial_agent_write_cannot_claim_an_unowned_note(owner, auto_resource_env):
"""Report failed writes without claiming a note whose source ownership is missing or different."""
env = auto_resource_env
source = env.write_binary("resource/2026-01-01/photo.png", image_bytes())
wrapper = FakeImageAgentWrapper(caption_fields("foreign", "Foreign", "A foreign-owned note."))
wrapper.note_metadata = {"source_resource": owner} if owner else {}
wrapper.after_write_error = RuntimeError("agent failed after writing")
step = env.processor(wrapper)
response = await env.run(step, [{"change": "added", "path": str(source)}])
result = response.metadata["results"][0]["metadata"]
assert result["error"] == "agent failed after writing"
assert not response.success
assert result["modified"] is response.metadata["modified"] is True
note = env.workspace / "daily/2026-01-01/photo.md"
before = note.read_bytes()
post = frontmatter.load(note)
assert post.get("source_resource") == owner
assert "kind" not in post and "media_type" not in post
assert not (env.workspace / "daily/2026-01-01/foreign.md").exists()
assert not (env.workspace / "daily/2026-01-01.md").exists()
deleted = await env.run(step, [{"change": "deleted", "path": str(source)}])
assert deleted.success
assert deleted.metadata["results"][0]["metadata"]["reason"] == "resource_note_not_found"
assert note.read_bytes() == before