Skip to content
Draft
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
75 changes: 75 additions & 0 deletions tests/core/test_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
from pathlib import Path
import subprocess

import pytest

Expand All @@ -25,3 +26,77 @@ def test_run_git_survives_non_utf8_output(tmp_path: Path, monkeypatch) -> None:

# The bad bytes are replaced with U+FFFD instead of crashing
assert "\ufffd" in result.stdout


def test_project_context_includes_git_file_overview(
tmp_path: Path, monkeypatch
) -> None:
def fake_run_git(
self: ProjectContextProvider, args: list[str], timeout: float
) -> subprocess.CompletedProcess[str]:
stdout = ""
if args == ["ls-files"]:
stdout = "src/main.py\nREADME.md\nsrc/main.py\n\n"
elif args == ["branch", "--show-current"]:
stdout = "main\n"
elif args == ["branch", "-r"]:
stdout = "origin/main\n"
elif args[:2] == ["log", "--oneline"]:
stdout = "abc123 init\n"
elif args != ["status", "--porcelain"]:
raise AssertionError(args)
return subprocess.CompletedProcess(args, 0, stdout=stdout)

monkeypatch.setattr(ProjectContextProvider, "_run_git", fake_run_git)

provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path)

context = provider.get_full_context()

assert "Project file overview (snapshot at conversation start):" in context
assert "- README.md" in context
assert "- src/main.py" in context
assert context.count("- src/main.py") == 1


def test_project_context_file_overview_is_bounded(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def fake_run_git(
self: ProjectContextProvider, args: list[str], timeout: float
) -> subprocess.CompletedProcess[str]:
stdout = ""
if args == ["ls-files"]:
stdout = "\n".join(f"file_{index}.py" for index in range(5))
return subprocess.CompletedProcess(args, 0, stdout=stdout)

monkeypatch.setattr(ProjectContextProvider, "_run_git", fake_run_git)
monkeypatch.setattr("vibe.core.system_prompt._MAX_FILE_OVERVIEW_ENTRIES", 2)
provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path)

overview = provider.get_file_overview()

assert "- file_0.py" in overview
assert "- file_1.py" in overview
assert "- file_2.py" not in overview
assert "... 3 more files omitted" in overview


def test_project_context_file_overview_falls_back_to_directory_scan(
tmp_path: Path, monkeypatch
) -> None:
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "module.py").write_text("", encoding="utf-8")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "ignored.js").write_text("", encoding="utf-8")
(tmp_path / ".hidden").write_text("", encoding="utf-8")

monkeypatch.setattr(ProjectContextProvider, "_list_git_files", lambda self: [])

provider = ProjectContextProvider(ProjectContextConfig(), root_path=tmp_path)

overview = provider.get_file_overview()

assert "- pkg/module.py" in overview
assert "ignored.js" not in overview
assert ".hidden" not in overview
2 changes: 2 additions & 0 deletions vibe/core/prompts/project_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ Absolute path: $abs_path

gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
$git_status

$file_overview
2 changes: 1 addition & 1 deletion vibe/core/skills/builtins/vibe.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@

# System prompt composition
include_model_info = true # Include model name in system prompt
include_project_context = true # Include project context (git info, cwd) in system prompt
include_project_context = true # Include project context (git info, cwd, file overview) in system prompt
include_prompt_detail = true # Include OS info, tool prompts, skills, and agents in system prompt

# Voice features
Expand Down
75 changes: 74 additions & 1 deletion vibe/core/system_prompt.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from datetime import date
import html
Expand Down Expand Up @@ -31,6 +32,17 @@
from vibe.core.tools.manager import ToolManager

_git_status_cache: dict[Path, str] = {}
_MAX_FILE_OVERVIEW_ENTRIES = 200


def _normalize_overview_entries(entries: Sequence[str]) -> list[str]:
clean_entries = []
for entry in entries:
clean = entry.strip()
if not clean:
continue
clean_entries.append(clean)
return sorted(dict.fromkeys(clean_entries))


class ProjectContextProvider:
Expand All @@ -40,6 +52,63 @@ def __init__(
self.root_path = Path(root_path).resolve()
self.config = config

def get_file_overview(self) -> str:
entries = self._list_project_files()
if not entries:
return "No project file overview available."

limit = _MAX_FILE_OVERVIEW_ENTRIES
visible = entries[:limit]
lines = ["Project file overview (snapshot at conversation start):"]
lines.extend(f"- {entry}" for entry in visible)
if len(entries) > limit:
lines.append(f"... {len(entries) - limit} more files omitted")
return "\n".join(lines)

def _list_project_files(self) -> list[str]:
if from_git := self._list_git_files():
return from_git
return self._list_directory_files()

def _list_git_files(self) -> list[str]:
try:
result = self._run_git(["ls-files"], min(self.config.timeout_seconds, 10.0))
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return []
return _normalize_overview_entries(result.stdout.splitlines())

def _list_directory_files(self) -> list[str]:
max_entries = _MAX_FILE_OVERVIEW_ENTRIES + 1
entries: list[str] = []
ignored_dirs = {
".git",
".hg",
".svn",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
"__pycache__",
"node_modules",
}
try:
for current_root, dirs, files in os.walk(self.root_path):
dirs[:] = [
d for d in dirs if d not in ignored_dirs and not d.startswith(".")
]
dirs.sort()
for file_name in sorted(files):
if file_name.startswith("."):
continue
path = Path(current_root, file_name)
entries.append(path.relative_to(self.root_path).as_posix())
if len(entries) >= max_entries:
return entries
except OSError:
return []
return entries

def get_git_status(self) -> str:
if self.root_path in _git_status_cache:
return _git_status_cache[self.root_path]
Expand Down Expand Up @@ -147,8 +216,12 @@ def get_full_context(self) -> str:
git_status = self.get_git_status()

template = UtilityPrompt.PROJECT_CONTEXT.read()
file_overview = self.get_file_overview()

return Template(template).safe_substitute(
abs_path=str(self.root_path), git_status=git_status
abs_path=str(self.root_path),
git_status=git_status,
file_overview=file_overview,
)


Expand Down
Loading