mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
test: cover a numbered read surviving a retry into a fresh attempt task
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING, Literal, cast, overload
|
||||
|
||||
import pytest
|
||||
from msgspec import UNSET
|
||||
@@ -928,6 +929,128 @@ async def test_retry_keeps_committed_tools_after_second_round_fails() -> None:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_retry_keeps_lineno_reads_across_attempt_tasks(tmp_path: object) -> None:
|
||||
"""A numbered read before a failed round still enables edits after retry.
|
||||
|
||||
Each turn attempt runs in its own task (the host wraps turns in
|
||||
``asyncio.create_task``), so the retry attempt's context does not carry the
|
||||
first attempt's ContextVar binding. Without ``retry`` re-binding the lineno
|
||||
tracker, ``read_file(with_lineno=true)`` silently records nothing and
|
||||
``edit_lineno`` rejects every line edit — even after re-reading the file as
|
||||
its own error message instructs.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from plyngent.tools.context import InstanceState
|
||||
from plyngent.tools.file import edit_lineno, read_file
|
||||
from plyngent.tools.workspace import (
|
||||
clear_workspace_allowlist,
|
||||
clear_workspace_root,
|
||||
set_workspace_root,
|
||||
)
|
||||
|
||||
root = cast("Path", tmp_path).resolve()
|
||||
(root / "f.txt").write_text("alpha\nbravo\ncharlie\n", encoding="utf-8")
|
||||
instance = InstanceState(workspace_root=root)
|
||||
from plyngent.tools.context import bind_instance
|
||||
|
||||
with bind_instance(instance):
|
||||
clear_workspace_allowlist()
|
||||
_ = set_workspace_root(root)
|
||||
try:
|
||||
registry = ToolRegistry([read_file, edit_lineno], instance_state=instance)
|
||||
|
||||
class ReadThenBoom:
|
||||
"""Round 1 reads with line numbers; round 2's model call fails."""
|
||||
|
||||
n: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.n = 0
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del stream
|
||||
self.n += 1
|
||||
if self.n == 1:
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c1",
|
||||
function=AssistantFunctionTool(
|
||||
name="read_file",
|
||||
arguments='{"path": "f.txt", "with_lineno": true}',
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
if self.n == 2:
|
||||
msg = "api down after read"
|
||||
raise RuntimeError(msg)
|
||||
if self.n == 3:
|
||||
edit_args = '{"path": "f.txt", "start_line": 2, "end_line": 2, "new_content": "BRAVO\\n"}'
|
||||
return _response(
|
||||
AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="c2",
|
||||
function=AssistantFunctionTool(
|
||||
name="edit_lineno",
|
||||
arguments=edit_args,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
return _response(AssistantChatMessage(content="done"))
|
||||
|
||||
client = ReadThenBoom()
|
||||
agent = ChatAgent(
|
||||
client,
|
||||
model="m",
|
||||
tools=registry,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
async def run_attempt() -> list[object]:
|
||||
return [e async for e in agent.run("edit line 2")]
|
||||
|
||||
# First attempt in its own task (mirrors run_cancellable): commits
|
||||
# the read, then the next model round fails.
|
||||
with pytest.raises(RuntimeError, match="api down after read"):
|
||||
await asyncio.create_task(run_attempt())
|
||||
assert isinstance(agent.messages[-1], ToolChatMessage)
|
||||
|
||||
# Retry in a *fresh* task: its context copy has no tracker bound.
|
||||
async def run_retry() -> list[object]:
|
||||
return [e async for e in agent.retry()]
|
||||
|
||||
events = await asyncio.create_task(run_retry())
|
||||
results = [e for e in events if isinstance(e, ToolResultEvent)]
|
||||
assert results, "retry must run the edit_lineno call"
|
||||
assert "replaced lines 2-2" in results[0].message.content
|
||||
assert (root / "f.txt").read_text(encoding="utf-8") == "alpha\nBRAVO\ncharlie\n"
|
||||
assert agent.pending_retry_text is None
|
||||
finally:
|
||||
clear_workspace_allowlist()
|
||||
clear_workspace_root()
|
||||
|
||||
|
||||
async def test_chat_agent_system_prompt_prepended() -> None:
|
||||
from plyngent.lmproto.openai_compatible.model import SystemChatMessage
|
||||
|
||||
|
||||
Reference in New Issue
Block a user