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
52 changes: 51 additions & 1 deletion tests/test_clink_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from clink import get_registry
from clink.agents import AgentOutput
from clink.parsers.base import ParsedCLIResponse
from tools.clink import MAX_RESPONSE_CHARS, CLinkTool
from tools.clink import MAX_RESPONSE_CHARS, CLinkRequest, CLinkTool


@pytest.mark.asyncio
Expand Down Expand Up @@ -185,3 +185,53 @@ async def run(self, **kwargs):
assert metadata.get("output_truncated") is True
assert metadata.get("events_removed_for_normal") is True
assert metadata.get("output_original_length") == len(long_text)


@pytest.mark.parametrize(
("cli_name", "expected_phrase"),
[
("claude", "Claude Code agent"),
("codex", "Codex CLI agent"),
("gemini", "Gemini CLI agent"),
],
)
def test_agent_capabilities_guidance_reflects_cli_name(cli_name, expected_phrase):
tool = CLinkTool()
client = tool._registry.get_client(cli_name)
guidance = tool._agent_capabilities_guidance(client)
assert expected_phrase in guidance
if cli_name != "gemini":
assert "Gemini CLI agent" not in guidance


@pytest.mark.asyncio
async def test_prepare_prompt_includes_cli_specific_guidance():
tool = CLinkTool()
client = tool._registry.get_client("claude")
role = client.get_role("default")
request = CLinkRequest(
prompt="Review auth module",
cli_name="claude",
role="default",
)

prompt = await tool._prepare_prompt_for_role(
request,
role,
client=client,
system_prompt="",
include_system_prompt=False,
)

assert "Claude Code agent" in prompt
assert "Gemini CLI agent" not in prompt
Comment on lines +208 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent regressions and ensure that prepare_prompt correctly handles the default case where cli_name is None, we should add a test case that invokes prepare_prompt directly with cli_name=None.

@pytest.mark.asyncio
async def test_prepare_prompt_includes_cli_specific_guidance():
    tool = CLinkTool()
    client = tool._registry.get_client("claude")
    role = client.get_role("default")
    request = CLinkRequest(
        prompt="Review auth module",
        cli_name="claude",
        role="default",
    )

    prompt = await tool._prepare_prompt_for_role(
        request,
        role,
        client=client,
        system_prompt="",
        include_system_prompt=False,
    )

    assert "Claude Code agent" in prompt
    assert "Gemini CLI agent" not in prompt


@pytest.mark.asyncio
async def test_prepare_prompt_with_default_cli():
    tool = CLinkTool()
    request = CLinkRequest(
        prompt="Review auth module",
        cli_name=None,
        role="default",
    )
    prompt = await tool.prepare_prompt(request)
    assert prompt is not None



@pytest.mark.asyncio
async def test_prepare_prompt_defaults_cli_name():
tool = CLinkTool()
request = CLinkRequest(prompt="Review auth module")

prompt = await tool.prepare_prompt(request)

assert "Gemini CLI agent" in prompt
20 changes: 16 additions & 4 deletions tools/clink.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@

MAX_RESPONSE_CHARS = 20_000
SUMMARY_PATTERN = re.compile(r"<SUMMARY>(.*?)</SUMMARY>", re.IGNORECASE | re.DOTALL)
_CLI_AGENT_DISPLAY_NAMES: dict[str, str] = {
"claude": "Claude Code agent",
"codex": "Codex CLI agent",
"gemini": "Gemini CLI agent",
}


class CLinkRequest(BaseModel):
Expand Down Expand Up @@ -196,6 +201,7 @@ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
prompt_text = await self._prepare_prompt_for_role(
request,
role_config,
client=client_config,
system_prompt=system_prompt_text,
include_system_prompt=include_system_prompt,
)
Expand Down Expand Up @@ -259,13 +265,17 @@ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]:
return [TextContent(type="text", text=tool_output.model_dump_json())]

async def prepare_prompt(self, request) -> str:
client_config = self._registry.get_client(request.cli_name)
selected_cli = request.cli_name or self._default_cli_name
if not selected_cli:
self._raise_tool_error("No CLI clients are configured for clink.")
client_config = self._registry.get_client(selected_cli)
role_config = client_config.get_role(request.role)
system_prompt_text = role_config.prompt_path.read_text(encoding="utf-8")
include_system_prompt = not self._use_external_system_prompt(client_config)
return await self._prepare_prompt_for_role(
request,
role_config,
client=client_config,
system_prompt=system_prompt_text,
include_system_prompt=include_system_prompt,
)
Comment on lines 275 to 281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a potential bug in prepare_prompt when request.cli_name is None (which is its default value).

Currently, line 268 directly calls self._registry.get_client(request.cli_name). If request.cli_name is None, this will raise an AttributeError: 'NoneType' object has no attribute 'lower' inside get_client.

To fix this, prepare_prompt should resolve the CLI name using self._default_cli_name as a fallback, similar to how it is done in execute:

async def prepare_prompt(self, request) -> str:
    selected_cli = request.cli_name or self._default_cli_name
    if not selected_cli:
        raise ValueError("No CLI clients are configured for clink.")
    client_config = self._registry.get_client(selected_cli)
    role_config = client_config.get_role(request.role)
    ...

Expand All @@ -275,14 +285,15 @@ async def _prepare_prompt_for_role(
request: CLinkRequest,
role: ResolvedCLIRole,
*,
client: ResolvedCLIClient,
system_prompt: str,
include_system_prompt: bool,
) -> str:
"""Load the role prompt and assemble the final user message."""
self._active_system_prompt = system_prompt
try:
user_content = self.handle_prompt_file_with_fallback(request).strip()
guidance = self._agent_capabilities_guidance()
guidance = self._agent_capabilities_guidance(client)
file_section = self._format_file_references(self.get_request_files(request))

sections: list[str] = []
Expand Down Expand Up @@ -438,9 +449,10 @@ def _raise_tool_error(self, message: str, metadata: dict[str, Any] | None = None
error_output = ToolOutput(status="error", content=message, content_type="text", metadata=metadata)
raise ToolExecutionError(error_output.model_dump_json())

def _agent_capabilities_guidance(self) -> str:
def _agent_capabilities_guidance(self, client: ResolvedCLIClient) -> str:
display_name = _CLI_AGENT_DISPLAY_NAMES.get(client.name.lower(), f"{client.name} CLI agent")
return (
"You are operating through the Gemini CLI agent. You have access to your full suite of "
f"You are operating through the {display_name}. You have access to your full suite of "
"CLI capabilities—including launching web searches, reading files, and using any other "
"available tools. Gather current information yourself and deliver the final answer without "
"asking the PAL MCP host to perform searches or file reads."
Expand Down
Loading