fix: resolve #93 review comments and close #95 (#92 regression fix included) (#96)

* feat: enhance file mention parsing with deduplication and warning handling

* feat: optimize ancestor grouping by improving path comparison efficiency

* fix: update middleware injection to use extend for better readability

* Refactor code structure for improved readability and maintainability

* feat: update README files to include AstaBench ranking and adjust award image layout

* update

* fix: deduplicate file mentions and improve warning message formatting
This commit is contained in:
Xi Zhang
2026-03-25 18:01:46 +01:00
committed by GitHub
parent 259842243d
commit fe6a2c4b83
12 changed files with 166 additions and 63 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

+2 -2
View File
@@ -141,8 +141,8 @@ def _inject_subagent_middleware(subs: list[dict]) -> None:
from .middleware import ContextOverflowMapperMiddleware, ToolErrorHandlerMiddleware from .middleware import ContextOverflowMapperMiddleware, ToolErrorHandlerMiddleware
for sa in subs: for sa in subs:
sa.setdefault("middleware", []).append( sa.setdefault("middleware", []).extend(
ToolErrorHandlerMiddleware(), ContextOverflowMapperMiddleware() [ToolErrorHandlerMiddleware(), ContextOverflowMapperMiddleware()]
) )
+40 -16
View File
@@ -182,7 +182,10 @@ def _read_file(path: Path) -> str:
return f"\n### {path.name}\nPath: `{path}`\n```\n{content}\n```" return f"\n### {path.name}\nPath: `{path}`\n```\n{content}\n```"
def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]: def parse_file_mentions(
text: str,
cwd: Path | None = None,
) -> tuple[list[Path], list[str]]:
"""Extract resolved ``@file`` paths from *text*. """Extract resolved ``@file`` paths from *text*.
Args: Args:
@@ -191,13 +194,19 @@ def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]:
process working directory. process working directory.
Returns: Returns:
List of resolved, existing ``Path`` objects (directories excluded). ``(files, warnings)`` — deduplicated list of resolved, existing
Unresolvable or missing paths are skipped with a printed warning. ``Path`` objects (directories excluded) in order of first appearance,
and a list of human-readable warning strings to be displayed by the
caller. Callers must display the warnings themselves using the
appropriate UI mechanism (Rich console, Textual widget, etc.).
""" """
if cwd is None: if cwd is None:
cwd = Path.cwd() cwd = Path.cwd()
workspace_root = cwd.resolve()
files: list[Path] = [] files: list[Path] = []
warnings: list[str] = []
seen: set[Path] = set()
for match in FILE_MENTION_PATTERN.finditer(text): for match in FILE_MENTION_PATTERN.finditer(text):
# Skip email addresses — character immediately before @ is alphanumeric # Skip email addresses — character immediately before @ is alphanumeric
before = text[: match.start()] before = text[: match.start()]
@@ -212,21 +221,35 @@ def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]:
if not p.is_absolute(): if not p.is_absolute():
p = cwd / p p = cwd / p
resolved = p.resolve() resolved = p.resolve()
if resolved.exists() and resolved.is_file(): if not resolved.exists() or not resolved.is_file():
files.append(resolved) warnings.append(f"@file not found: {raw}")
else: continue
print(f"[warning] @file not found: {raw}") # Deduplicate: skip paths already seen in this message.
if resolved in seen:
continue
seen.add(resolved)
files.append(resolved)
# Warn when the file lives outside the workspace root — it may
# contain sensitive content (e.g. @~/.ssh/id_rsa).
# Checked after dedup so a repeated mention only warns once.
try:
resolved.relative_to(workspace_root)
except ValueError:
warnings.append(
f"@{raw} is outside the workspace "
f"({workspace_root}) — embedding may expose sensitive files"
)
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
print(f"[warning] invalid @file path {raw!r}: {exc}") warnings.append(f"invalid @file path {raw!r}: {exc}")
return files return files, warnings
def resolve_file_mentions( def resolve_file_mentions(
text: str, text: str,
workspace_dir: str | None = None, workspace_dir: str | None = None,
) -> tuple[str, str]: ) -> tuple[str, str, list[str]]:
"""Parse ``@file`` mentions and return *(original_text, final_prompt)*. """Parse ``@file`` mentions and return *(original_text, final_prompt, warnings)*.
*final_prompt* equals *original_text* when no valid mentions are found, *final_prompt* equals *original_text* when no valid mentions are found,
otherwise it appends a ``## Referenced Files`` section with the file otherwise it appends a ``## Referenced Files`` section with the file
@@ -237,14 +260,15 @@ def resolve_file_mentions(
workspace_dir: Workspace root used for resolving relative paths. workspace_dir: Workspace root used for resolving relative paths.
Returns: Returns:
``(original_text, final_prompt)`` — the first element is always the ``(original_text, final_prompt, warnings)`` — the first element is
unchanged input; the second is the prompt to send to the agent. always the unchanged input; the second is the prompt to send to the
agent; the third is a list of warning strings to display to the user.
""" """
cwd = Path(workspace_dir) if workspace_dir else None cwd = Path(workspace_dir) if workspace_dir else None
files = parse_file_mentions(text, cwd=cwd) files, warnings = parse_file_mentions(text, cwd=cwd)
if not files: if not files:
return text, text return text, text, warnings
parts = [text, "\n\n## Referenced Files\n"] parts = [text, "\n\n## Referenced Files\n"]
for path in files: for path in files:
@@ -253,7 +277,7 @@ def resolve_file_mentions(
except (OSError, UnicodeDecodeError) as exc: except (OSError, UnicodeDecodeError) as exc:
parts.append(f"\n### {path.name}\n[Error reading file: {exc}]") parts.append(f"\n### {path.name}\n[Error reading file: {exc}]")
return text, "\n".join(parts) return text, "\n".join(parts), warnings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5 -1
View File
@@ -922,11 +922,15 @@ def cmd_interactive(
continue continue
# Resolve @file mentions — inject file contents inline # Resolve @file mentions — inject file contents inline
_, message_to_send = resolve_file_mentions( _, message_to_send, file_warnings = resolve_file_mentions(
user_input, state["workspace_dir"] user_input, state["workspace_dir"]
) )
# Stream agent response with metadata for persistence # Stream agent response with metadata for persistence
# Warnings printed here so they appear just before the
# model response, not before the user input echo.
for w in file_warnings:
console.print(f"[yellow]⚠ {escape(w)}[/yellow]")
console.print() console.print()
meta = build_metadata(state["workspace_dir"], model) meta = build_metadata(state["workspace_dir"], model)
run_streaming( run_streaming(
+17 -4
View File
@@ -700,6 +700,7 @@ def run_textual_interactive(
on_todo_cb: Callable[[list[dict]], None] | None = None, on_todo_cb: Callable[[list[dict]], None] | None = None,
on_media_cb: Callable[[str], None] | None = None, on_media_cb: Callable[[str], None] | None = None,
skip_user_message: bool = False, skip_user_message: bool = False,
file_warnings: list[str] | None = None,
channel_hitl_fn: Callable[[list], list[dict] | None] | None = None, channel_hitl_fn: Callable[[list], list[dict] | None] | None = None,
channel_ask_user_fn: Callable[[dict], dict] | None = None, channel_ask_user_fn: Callable[[dict], dict] | None = None,
) -> str: ) -> str:
@@ -723,6 +724,10 @@ def run_textual_interactive(
# 1. Mount user message + loading spinner # 1. Mount user message + loading spinner
if not skip_user_message: if not skip_user_message:
await container.mount(UserMessage(user_text)) await container.mount(UserMessage(user_text))
# Mount file warnings after user message so they appear in the
# correct position (between user input and model response).
for w in file_warnings or []:
self._append_system(f"⚠ {w}", style="yellow")
loading = LoadingWidget() loading = LoadingWidget()
await container.mount(loading) await container.mount(loading)
container.scroll_end(animate=False) container.scroll_end(animate=False)
@@ -1388,13 +1393,17 @@ def run_textual_interactive(
self._render_status() self._render_status()
cancelled = False cancelled = False
# Resolve @file mentions — inject file contents before sending to agent # Resolve @file mentions — inject file contents before sending to agent.
_, message_to_send = await asyncio.to_thread( # Use self._workspace_dir (current session) not the startup-captured
resolve_file_mentions, user_text, workspace_dir # workspace_dir closure, which becomes stale after /new or /resume.
_, message_to_send, file_warnings = await asyncio.to_thread(
resolve_file_mentions, user_text, self._workspace_dir
) )
try: try:
await self._stream_with_widgets(message_to_send) await self._stream_with_widgets(
message_to_send, file_warnings=file_warnings
)
except asyncio.CancelledError: except asyncio.CancelledError:
cancelled = True cancelled = True
self._append_system("\nInterrupted by user", style="dim italic #ffe082") self._append_system("\nInterrupted by user", style="dim italic #ffe082")
@@ -2000,6 +2009,10 @@ def run_textual_interactive(
lambda: setattr(self, "_quit_pending", False), lambda: setattr(self, "_quit_pending", False),
) )
def force_quit(self) -> None:
"""Exit immediately without double-press confirmation (used by /exit command)."""
self._do_exit()
def _do_exit(self) -> None: def _do_exit(self) -> None:
"""Clean up channels and exit.""" """Clean up channels and exit."""
if self._channel_timer is not None: if self._channel_timer is not None:
+20 -11
View File
@@ -73,19 +73,28 @@ def _group_by_ancestor(norm_paths: list[str]) -> dict[str, list[str]]:
full path. full path.
The returned dict is ordered by first appearance in *norm_paths*. The returned dict is ordered by first appearance in *norm_paths*.
Complexity: O(n log n) — paths are sorted lexicographically so the
maximum common prefix depth for each path is found by comparing only
its immediate neighbours in sorted order (not all pairs).
""" """
path_to_ancestor: dict[str, str] = {} sorted_paths = sorted(norm_paths)
for i, p in enumerate(norm_paths): n = len(sorted_paths)
# Single pass over sorted list: max common prefix is always with a neighbour.
sorted_best: dict[str, int] = {}
for i, p in enumerate(sorted_paths):
best = 1 # at minimum depth 1 (~) best = 1 # at minimum depth 1 (~)
for j, other in enumerate(norm_paths): if i > 0:
if i != j: best = max(best, _common_prefix_depth(p, sorted_paths[i - 1]))
best = max(best, _common_prefix_depth(p, other)) if i < n - 1:
# Only group if they truly share a meaningful ancestor (>= 2 levels) best = max(best, _common_prefix_depth(p, sorted_paths[i + 1]))
if best >= 2: sorted_best[p] = best
ancestor = "/".join(p.split("/")[:best])
else: path_to_ancestor: dict[str, str] = {
ancestor = p # standalone p: ("/".join(p.split("/")[:best]) if best >= 2 else p)
path_to_ancestor[p] = ancestor for p, best in sorted_best.items()
}
groups: dict[str, list[str]] = {} groups: dict[str, list[str]] = {}
for p in norm_paths: for p in norm_paths:
+1
View File
@@ -37,6 +37,7 @@ class CommandUI(Protocol):
) -> list | None: ... ) -> list | None: ...
def clear_chat(self) -> None: ... def clear_chat(self) -> None: ...
def request_quit(self) -> None: ... def request_quit(self) -> None: ...
def force_quit(self) -> None: ...
def start_new_session(self) -> None: ... def start_new_session(self) -> None: ...
async def handle_session_resume( async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None self, thread_id: str, workspace_dir: str | None = None
+3
View File
@@ -126,6 +126,9 @@ class ChannelCommandUI(CommandUI):
def request_quit(self) -> None: def request_quit(self) -> None:
self.append_system("Quit command ignored in channel.") self.append_system("Quit command ignored in channel.")
def force_quit(self) -> None:
self.request_quit()
def start_new_session(self) -> None: def start_new_session(self) -> None:
if self.start_new_session_callback: if self.start_new_session_callback:
self.start_new_session_callback() self.start_new_session_callback()
@@ -261,7 +261,7 @@ class ExitCommand(Command):
description = "Quit EvoScientist" description = "Quit EvoScientist"
async def execute(self, ctx: CommandContext, args: list[str]) -> None: async def execute(self, ctx: CommandContext, args: list[str]) -> None:
ctx.ui.request_quit() ctx.ui.force_quit()
# Register session commands # Register session commands
+9 -3
View File
@@ -46,21 +46,26 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
<table> <table>
<tr> <tr>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_awards.JPG" height="180" alt="ICAIS 2025 Awards"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_awards.JPG" height="180" alt="ICAIS 2025 Awards"/>
<br /> <br />
<sub><b>Best Paper & Appraisal Award</b></sub> <sub><b>Best Paper & Appraisal Award</b></sub>
</td> </td>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_best_paper.png" height="180" alt="Best Paper"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_best_paper.png" height="180" alt="Best Paper"/>
<br /> <br />
<sub><b>AI-Generated Best Paper</b></sub> <sub><b>AI-Generated Best Paper</b></sub>
</td> </td>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/deepresearch_bench_2.JPG" height="180" alt="DeepResearch Bench II #1"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/deepresearch_bench_2.JPG" height="180" alt="DeepResearch Bench II #1"/>
<br /> <br />
<sub><b>#1 on DeepResearch Bench II</b></sub> <sub><b>#1 on DeepResearch Bench II</b></sub>
</td> </td>
<td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/asta_bench_code.png" height="180" alt="AstaBench Code & Execution #1"/>
<br />
<sub><b>#1 on AstaBench Code & Execution</b></sub>
</td>
</tr> </tr>
</table> </table>
@@ -97,6 +102,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
> Looking for ready-to-use research skills? Check out [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) — powered by [**EvoScientist**](https://github.com/EvoScientist/EvoScientist)'s engine and installable skills, the entire end-to-end research lifecycle is covered out of the box. [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) are also compatible with other CLI coding agents. > Looking for ready-to-use research skills? Check out [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) — powered by [**EvoScientist**](https://github.com/EvoScientist/EvoScientist)'s engine and installable skills, the entire end-to-end research lifecycle is covered out of the box. [**EvoSkills**](https://github.com/EvoScientist/EvoSkills) are also compatible with other CLI coding agents.
## 🔥 News ## 🔥 News
- **[25 Mar 2026]** 🥇 Ranked #1 on [AstaBench Code & Execution](https://huggingface.co/spaces/allenai/asta-bench-leaderboard) at submission time! [**Leaderboard**](https://allenai-asta-bench-leaderboard.hf.space/code-execution) 👈
- **[13 Mar 2026]** 🚀 [**EvoScientist**](https://github.com/EvoScientist/EvoScientist) officially debuts! - **[13 Mar 2026]** 🚀 [**EvoScientist**](https://github.com/EvoScientist/EvoScientist) officially debuts!
- **[11 Mar 2026]** ⛳ Technical Report is live! [**Check it out**](https://arxiv.org/abs/2603.08127) 👈 - **[11 Mar 2026]** ⛳ Technical Report is live! [**Check it out**](https://arxiv.org/abs/2603.08127) 👈
- **[06 Mar 2026]** 🥇 Ranked #1 on [DeepResearch Bench II](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) at submission time! [**Leaderboard**](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 👈 - **[06 Mar 2026]** 🥇 Ranked #1 on [DeepResearch Bench II](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) at submission time! [**Leaderboard**](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 👈
+9 -3
View File
@@ -53,21 +53,26 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
<table> <table>
<tr> <tr>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_awards.JPG" height="180" alt="ICAIS 2025 Awards"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_awards.JPG" height="180" alt="ICAIS 2025 Awards"/>
<br /> <br />
<sub><b>Best Paper & Appraisal Award</b></sub> <sub><b>Best Paper & Appraisal Award</b></sub>
</td> </td>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_best_paper.png" height="180" alt="Best Paper"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/ICAIS_best_paper.png" height="180" alt="Best Paper"/>
<br /> <br />
<sub><b>AI-Generated Best Paper</b></sub> <sub><b>AI-Generated Best Paper</b></sub>
</td> </td>
<td align="center" valign="top" width="33%"> <td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/deepresearch_bench_2.JPG" height="180" alt="DeepResearch Bench II #1"/> <img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/deepresearch_bench_2.JPG" height="180" alt="DeepResearch Bench II #1"/>
<br /> <br />
<sub><b>DeepResearch Bench II 第一名</b></sub> <sub><b>DeepResearch Bench II 第一名</b></sub>
</td> </td>
<td align="center" valign="top" width="25%">
<img src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/asta_bench_code.png" height="180" alt="AstaBench Code & Execution #1"/>
<br />
<sub><b>AstaBench 代码与执行榜 第一名</b></sub>
</td>
</tr> </tr>
</table> </table>
@@ -106,6 +111,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
## 🔥 动态 ## 🔥 动态
- **[2026 年 3 月 25 日]** 🥇 提交时在 [AstaBench 代码与执行](https://huggingface.co/spaces/allenai/asta-bench-leaderboard) 排名第一![**排行榜**](https://allenai-asta-bench-leaderboard.hf.space/code-execution) 👈
- **[2026 年 3 月 13 日]** 🚀 [**EvoScientist**](https://github.com/EvoScientist/EvoScientist) 正式亮相! - **[2026 年 3 月 13 日]** 🚀 [**EvoScientist**](https://github.com/EvoScientist/EvoScientist) 正式亮相!
- **[2026 年 3 月 11 日]** ⛳ 技术报告已上线![**查看详情**](https://arxiv.org/abs/2603.08127) 👈 - **[2026 年 3 月 11 日]** ⛳ 技术报告已上线![**查看详情**](https://arxiv.org/abs/2603.08127) 👈
- **[2026 年 3 月 6 日]** 🥇 提交时在 [DeepResearch Bench II](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 排名第一![**排行榜**](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 👈 - **[2026 年 3 月 6 日]** 🥇 提交时在 [DeepResearch Bench II](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 排名第一![**排行榜**](https://agentresearchlab.com/benchmarks/deepresearch-bench-ii/index.html#leaderboard) 👈
+59 -22
View File
@@ -20,47 +20,74 @@ class TestParseFileMentions:
def test_single_mention(self, tmp_path: Path) -> None: def test_single_mention(self, tmp_path: Path) -> None:
f = tmp_path / "paper.tex" f = tmp_path / "paper.tex"
f.write_text("hello") f.write_text("hello")
result = parse_file_mentions(f"read @{f}", cwd=tmp_path) files, _ = parse_file_mentions(f"read @{f}", cwd=tmp_path)
assert result == [f.resolve()] assert files == [f.resolve()]
def test_relative_mention(self, tmp_path: Path) -> None: def test_relative_mention(self, tmp_path: Path) -> None:
f = tmp_path / "notes.md" f = tmp_path / "notes.md"
f.write_text("world") f.write_text("world")
result = parse_file_mentions("check @notes.md", cwd=tmp_path) files, _ = parse_file_mentions("check @notes.md", cwd=tmp_path)
assert result == [f.resolve()] assert files == [f.resolve()]
def test_multiple_mentions(self, tmp_path: Path) -> None: def test_multiple_mentions(self, tmp_path: Path) -> None:
a = tmp_path / "a.py" a = tmp_path / "a.py"
b = tmp_path / "b.py" b = tmp_path / "b.py"
a.write_text("a") a.write_text("a")
b.write_text("b") b.write_text("b")
result = parse_file_mentions(f"diff @{a} @{b}", cwd=tmp_path) files, _ = parse_file_mentions(f"diff @{a} @{b}", cwd=tmp_path)
assert set(result) == {a.resolve(), b.resolve()} assert set(files) == {a.resolve(), b.resolve()}
def test_missing_file_skipped(self, tmp_path: Path) -> None: def test_missing_file_skipped(self, tmp_path: Path) -> None:
result = parse_file_mentions("@nonexistent.txt", cwd=tmp_path) files, warnings = parse_file_mentions("@nonexistent.txt", cwd=tmp_path)
assert result == [] assert files == []
assert any("not found" in w for w in warnings)
def test_email_address_ignored(self, tmp_path: Path) -> None: def test_email_address_ignored(self, tmp_path: Path) -> None:
result = parse_file_mentions("send to user@example.com", cwd=tmp_path) files, warnings = parse_file_mentions("send to user@example.com", cwd=tmp_path)
assert result == [] assert files == []
assert warnings == []
def test_directory_excluded(self, tmp_path: Path) -> None: def test_directory_excluded(self, tmp_path: Path) -> None:
d = tmp_path / "subdir" d = tmp_path / "subdir"
d.mkdir() d.mkdir()
result = parse_file_mentions(f"@{d}", cwd=tmp_path) files, _ = parse_file_mentions(f"@{d}", cwd=tmp_path)
assert result == [] assert files == []
def test_bare_at_sign_ignored(self, tmp_path: Path) -> None: def test_bare_at_sign_ignored(self, tmp_path: Path) -> None:
result = parse_file_mentions("hello @ world", cwd=tmp_path) files, warnings = parse_file_mentions("hello @ world", cwd=tmp_path)
assert result == [] assert files == []
assert warnings == []
def test_tilde_expansion(self, tmp_path: Path, monkeypatch) -> None: def test_tilde_expansion(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("HOME", str(tmp_path))
f = tmp_path / "file.txt" f = tmp_path / "file.txt"
f.write_text("x") f.write_text("x")
result = parse_file_mentions("@~/file.txt", cwd=tmp_path) files, _ = parse_file_mentions("@~/file.txt", cwd=tmp_path)
assert result == [f.resolve()] assert files == [f.resolve()]
def test_duplicate_mention_deduplicated(self, tmp_path: Path) -> None:
"""Mentioning the same file twice returns it only once."""
f = tmp_path / "file.txt"
f.write_text("hello")
files, _ = parse_file_mentions(f"@{f} and again @{f}", cwd=tmp_path)
assert files == [f.resolve()]
def test_outside_workspace_warns(self, tmp_path: Path) -> None:
"""Files outside the workspace root trigger a warning but are still returned."""
outside = tmp_path.parent / "secret.txt"
outside.write_text("secret")
workspace = tmp_path / "workspace"
workspace.mkdir()
files, warnings = parse_file_mentions(f"@{outside}", cwd=workspace)
assert files == [outside.resolve()]
assert any("outside the workspace" in w for w in warnings)
def test_inside_workspace_no_warning(self, tmp_path: Path) -> None:
"""Files inside the workspace do NOT produce an outside-workspace warning."""
f = tmp_path / "safe.txt"
f.write_text("data")
_, warnings = parse_file_mentions(f"@{f}", cwd=tmp_path)
assert not any("outside the workspace" in w for w in warnings)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -71,15 +98,16 @@ class TestParseFileMentions:
class TestResolveFileMentions: class TestResolveFileMentions:
def test_no_mentions_returns_original(self, tmp_path: Path) -> None: def test_no_mentions_returns_original(self, tmp_path: Path) -> None:
text = "just a normal message" text = "just a normal message"
original, final = resolve_file_mentions(text, str(tmp_path)) original, final, warnings = resolve_file_mentions(text, str(tmp_path))
assert original == text assert original == text
assert final == text assert final == text
assert warnings == []
def test_mention_appends_content(self, tmp_path: Path) -> None: def test_mention_appends_content(self, tmp_path: Path) -> None:
f = tmp_path / "README.md" f = tmp_path / "README.md"
f.write_text("# Hello") f.write_text("# Hello")
text = f"summarise @{f}" text = f"summarise @{f}"
original, final = resolve_file_mentions(text, str(tmp_path)) original, final, _ = resolve_file_mentions(text, str(tmp_path))
assert original == text assert original == text
assert "## Referenced Files" in final assert "## Referenced Files" in final
assert "README.md" in final assert "README.md" in final
@@ -90,7 +118,7 @@ class TestResolveFileMentions:
# Write more than 256 KB # Write more than 256 KB
f.write_bytes(b"x" * (260 * 1024)) f.write_bytes(b"x" * (260 * 1024))
text = f"read @{f}" text = f"read @{f}"
_, final = resolve_file_mentions(text, str(tmp_path)) _, final, _ = resolve_file_mentions(text, str(tmp_path))
assert "too large to embed" in final assert "too large to embed" in final
assert "read_file" in final assert "read_file" in final
@@ -99,7 +127,7 @@ class TestResolveFileMentions:
b = tmp_path / "b.txt" b = tmp_path / "b.txt"
a.write_text("AAA") a.write_text("AAA")
b.write_text("BBB") b.write_text("BBB")
_, final = resolve_file_mentions(f"@{a} and @{b}", str(tmp_path)) _, final, _ = resolve_file_mentions(f"@{a} and @{b}", str(tmp_path))
assert "AAA" in final assert "AAA" in final
assert "BBB" in final assert "BBB" in final
@@ -107,16 +135,25 @@ class TestResolveFileMentions:
f = tmp_path / "f.txt" f = tmp_path / "f.txt"
f.write_text("content") f.write_text("content")
text = f"look at @{f} please" text = f"look at @{f} please"
_, final = resolve_file_mentions(text, str(tmp_path)) _, final, _ = resolve_file_mentions(text, str(tmp_path))
assert final.startswith(text) assert final.startswith(text)
def test_none_workspace_uses_cwd(self, tmp_path: Path, monkeypatch) -> None: def test_none_workspace_uses_cwd(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
f = tmp_path / "local.txt" f = tmp_path / "local.txt"
f.write_text("local") f.write_text("local")
_, final = resolve_file_mentions("@local.txt", None) _, final, _ = resolve_file_mentions("@local.txt", None)
assert "local" in final assert "local" in final
def test_warnings_returned_not_printed(self, tmp_path: Path) -> None:
"""Warnings are returned in the third element, not printed to stdout."""
outside = tmp_path.parent / "outside.txt"
outside.write_text("sensitive")
workspace = tmp_path / "workspace"
workspace.mkdir()
_, _, warnings = resolve_file_mentions(f"@{outside}", str(workspace))
assert any("outside the workspace" in w for w in warnings)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# complete_file_mention # complete_file_mention