Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions frontends/fsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
138 changes: 138 additions & 0 deletions frontends/tests/test_fsapp_files.py
Original file line number Diff line number Diff line change
@@ -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"