From b1ae4321b7894247a04be118f20c02cfe421b2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E5=92=95=E5=92=95?= <42111195+lexingtonhibiki@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:42:34 +0900 Subject: [PATCH 1/2] fix(feishu): resolve file paths in workdir [skip ci] Resolve relative generated-file markers against the current handler.cwd before the legacy process-relative fallback; preserve absolute paths. Related to lsdefine/GenericAgent#628. AI-assisted implementation; regression coverage follows in the next commit. --- frontends/fsapp.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontends/fsapp.py b/frontends/fsapp.py index ad959f660..8c0508947 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -552,7 +552,10 @@ def _describe_media(msg_type, file_path, filename): return f"[{msg_type}]\n[File: source: {file_path}]" -def _send_local_file(receive_id, file_path, receive_id_type="open_id"): +def _send_local_file(receive_id, file_path, receive_id_type="open_id", cwd=None): + if cwd and not os.path.isabs(file_path): + candidate = os.path.join(cwd, file_path) + if os.path.isfile(candidate): file_path = candidate if not os.path.isfile(file_path): send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type) return False @@ -572,9 +575,9 @@ def _send_local_file(receive_id, file_path, receive_id_type="open_id"): return False -def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"): +def _send_generated_files(receive_id, raw_text, receive_id_type="open_id", cwd=None): for file_path in _extract_files(raw_text): - _send_local_file(receive_id, file_path, receive_id_type) + _send_local_file(receive_id, file_path, receive_id_type, cwd) def _build_user_message(message): @@ -733,7 +736,8 @@ async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type rid = receive_id or chat_id text = _display_text(raw_text) await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type) - await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type) + cwd = getattr(getattr(self.agent, "handler", None), "cwd", None) + await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type, cwd) async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_): if self.user_tasks: @@ -755,7 +759,8 @@ def _finish(raw): result["raw"] = raw result["sent"] = True card.done(_display_text(raw)) - _send_generated_files(rid, raw, receive_id_type=receive_id_type) + cwd = getattr(getattr(self.agent, "handler", None), "cwd", None) + _send_generated_files(rid, raw, receive_id_type=receive_id_type, cwd=cwd) try: await asyncio.to_thread(card.start) From 01bd299c4935a44b8ee622bb59518533cb1b3739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E5=92=95=E5=92=95?= <42111195+lexingtonhibiki@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:44:29 +0900 Subject: [PATCH 2/2] test(feishu): cover generated-file workdir paths Add 13 offline regressions for real fsapp helpers and completion paths, using the repository's AST contract-test pattern and a fake uploader. Cover workdir-relative, absolute and legacy paths, same-name collisions, missing files/directories, and direct, queue and hook completion. No live Lark/account/model calls. AI-assisted implementation and validation. --- frontends/tests/test_fsapp_files.py | 138 ++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontends/tests/test_fsapp_files.py diff --git a/frontends/tests/test_fsapp_files.py b/frontends/tests/test_fsapp_files.py new file mode 100644 index 000000000..cf9744597 --- /dev/null +++ b/frontends/tests/test_fsapp_files.py @@ -0,0 +1,138 @@ +"""Exercise fsapp's real file helpers and completion paths without Lark or a model. + +As in the bridge contracts, AST loading avoids module-level configuration and +SDK startup. UI/network boundaries and queued task execution are fake; helpers, +completion methods and the task hook are loaded directly from production source. +""" +import ast +import asyncio +import json +import os +from pathlib import Path +import queue +import re +import threading +import time +from types import SimpleNamespace +from unittest.mock import Mock +import uuid + +import pytest + +SOURCE = Path(__file__).resolve().parents[1] / "fsapp.py" + + +@pytest.fixture +def files(tmp_path, monkeypatch): + root, work = tmp_path / "repo", tmp_path / "custom 工作目录" + root.mkdir() + work.mkdir() + monkeypatch.chdir(root) + uploads, messages = [], [] + + def upload(path): + uploads.append((Path(path).resolve(), Path(path).read_bytes())) + return "fake-key" + + class ChatMixin: + def __init__(self, agent, user_tasks): + self.agent, self.user_tasks = agent, user_tasks + + ns = { + "os": os, "json": json, "re": re, "asyncio": asyncio, + "Q": queue, "threading": threading, "time": time, "uuid": uuid, + "traceback": __import__("traceback"), + "AgentChatMixin": ChatMixin, "_TaskCard": Mock(), + "_display_text": lambda text: text, "FILE_HINT": "[FILE:filepath]", + "AGENT_TIMEOUT_SEC": 1, + "_IMAGE_EXTS": {".png"}, "_AUDIO_EXTS": {".opus"}, "_VIDEO_EXTS": {".mp4"}, + "_upload_file_sync": upload, "_upload_image_sync": upload, + "send_message": lambda *a, **kw: messages.append((a, kw)), + } + names = {"_extract_files", "_send_local_file", "_send_generated_files", + "_make_task_hook", "FeishuApp"} + nodes = [n for n in ast.parse(SOURCE.read_text(encoding="utf-8")).body + if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in names] + assert len(nodes) == len(names) + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SOURCE), "exec"), ns) + return SimpleNamespace(root=root, work=work, uploads=uploads, messages=messages, ns=ns) + + +@pytest.mark.parametrize("marker", ["report.txt", "reports/研究 报告.txt", "picture.png"]) +def test_relative_file_uses_agent_cwd(files, marker): + target = files.work / marker + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"generated") + files.ns["_send_generated_files"]("chat", f"[FILE:{marker}]", cwd=str(files.work)) + assert files.uploads == [(target, b"generated")] + + +def test_agent_file_wins_over_same_name_in_process_cwd(files): + (files.work / "report.txt").write_bytes(b"generated") + (files.root / "report.txt").write_bytes(b"unrelated") + files.ns["_send_generated_files"]("chat", "[FILE:report.txt]", cwd=str(files.work)) + assert files.uploads == [(files.work / "report.txt", b"generated")] + + +@pytest.mark.parametrize("marker", ["report.txt", "./temp/report.txt"]) +def test_existing_process_relative_file_remains_usable(files, marker): + target = files.root / marker + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"legacy") + files.ns["_send_generated_files"]("chat", f"[FILE:{marker}]", cwd=str(files.work)) + assert files.uploads == [(target.resolve(), b"legacy")] + + +def test_absolute_file_is_unchanged(files): + target = files.root / "absolute.txt" + target.write_bytes(b"absolute") + files.ns["_send_generated_files"]("chat", f"[FILE:{target}]", cwd=str(files.work)) + assert files.uploads == [(target, b"absolute")] + + +@pytest.mark.parametrize("marker", ["missing.txt", "reports"]) +def test_missing_file_or_directory_is_not_uploaded(files, marker): + (files.work / "reports").mkdir() + files.ns["_send_generated_files"]("chat", f"[FILE:{marker}]", cwd=str(files.work)) + assert files.uploads == [] + assert "文件不存在" in files.messages[0][0][1] + + +def test_no_handler_preserves_legacy_completion(files): + target = files.root / "legacy.txt" + target.write_bytes(b"legacy") + app = files.ns["FeishuApp"](SimpleNamespace(), {}) + asyncio.run(app.send_done("chat", "[FILE:legacy.txt]")) + assert files.uploads == [(target, b"legacy")] + + +@pytest.mark.parametrize("completion", ["send_done", "queue", "hook"]) +def test_completion_passes_current_handler_cwd(files, completion): + target = files.work / "report.txt" + target.write_bytes(b"generated") + raw = "[FILE:report.txt]" + agent = SimpleNamespace(handler=SimpleNamespace(cwd=str(files.work)), abort=Mock()) + + def put_task(*args, **kwargs): + if completion == "hook": + ctx = {"self": SimpleNamespace(parent=agent), "exit_reason": "done", + "response": SimpleNamespace(content=raw)} + for hook in agent._turn_end_hooks.values(): + hook(ctx) + out = queue.Queue() + out.put({"done": raw}) + return out + + agent.put_task = put_task + app = files.ns["FeishuApp"](agent, {}) + if completion == "send_done": + asyncio.run(app.send_done("chat", raw, receive_id="group", receive_id_type="chat_id")) + else: + asyncio.run(app.run_agent("chat", "make a report", receive_id="group", receive_id_type="chat_id")) + assert app.user_tasks == {} + assert agent._turn_end_hooks == {} + assert files.uploads == [(target, b"generated")] + sent = [m for m in files.messages if m[1].get("msg_type") == "file"] + assert len(sent) == 1 + assert sent[0][0][0] == "group" + assert sent[0][1]["receive_id_type"] == "chat_id"