Skip to content

Commit f2b80ca

Browse files
author
Chojan Shang
committed
*: update with more case and polished docs
Signed-off-by: Chojan Shang <chojan.shang@vesoft.com>
1 parent 2c4a39b commit f2b80ca

File tree

4 files changed

+226
-0
lines changed

4 files changed

+226
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Full example with streaming and lifecycle hooks lives in [examples/echo_agent.py
136136
- Project docs (MkDocs): https://psiace.github.io/agent-client-protocol-python/
137137
- Local sources: `docs/`
138138
- [Quickstart](docs/quickstart.md)
139+
- [Releasing](docs/releasing.md)
139140

140141
## Gemini CLI bridge
141142

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ An opt-in smoke test lives at `tests/test_gemini_example.py`. Enable it with `AC
4646
## Documentation map
4747

4848
- [Quickstart](quickstart.md): install, run, and embed the echo agent, plus next steps for extending it
49+
- [Releasing](releasing.md): schema upgrade workflow, version bumps, and publishing checklist
4950

5051
Source code lives under `src/acp/`, while tests and additional examples are available in `tests/` and `examples/`. If you plan to contribute, see the repository README for the development workflow.

docs/releasing.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Releasing
2+
3+
This project tracks the ACP schema tags published by
4+
[`agentclientprotocol/agent-client-protocol`](https://github.com/agentclientprotocol/agent-client-protocol).
5+
Every release should line up with one of those tags so that the generated `acp.schema` module, examples, and package
6+
version remain consistent.
7+
8+
## 准备阶段
9+
10+
1. 选择目标 schema 版本(例如 `v0.4.5`),并重新生成协议文件:
11+
12+
```bash
13+
ACP_SCHEMA_VERSION=v0.4.5 make gen-all
14+
```
15+
16+
该命令会下载对应的 schema 包并重写 `schema/``src/acp/schema.py`
17+
18+
2. 同步更新 `pyproject.toml` 中的版本号,并根据需要调整 `uv.lock`
19+
20+
3. 运行基础校验:
21+
22+
```bash
23+
make check
24+
make test
25+
```
26+
27+
`make check` 会执行 Ruff 格式化/静态检查、类型分析以及依赖完整性校验;`make test` 则运行 pytest(含 doctest)。
28+
29+
4. 更新文档与示例(例如 Gemini 集成)以反映变化。
30+
31+
## 提交与合并
32+
33+
1. 确认 diff 仅包含预期变动:schema 源文件、生成的 Pydantic 模型、版本号以及相应文档。
34+
2. 使用 Conventional Commits(如 `release: v0.4.5`)提交,并在 PR 中记录:
35+
- 引用的 ACP schema 标签
36+
- `make check` / `make test` 的结果
37+
- 重要的行为或 API 变更
38+
3. 获得评审通过后合并 PR。
39+
40+
## 通过 GitHub Release 触发发布
41+
42+
仓库采用 GitHub Workflow (`on-release-main.yml`) 自动完成发布。主干合并完成后:
43+
44+
1. 在 GitHub Releases 页面创建新的 Release,选择目标标签(形如 `v0.4.5`)。如标签不存在,Release 创建过程会自动打上该标签。
45+
2. Release 发布后,工作流会:
46+
- 将标签写回 `pyproject.toml`(以保证包版本与标签一致)
47+
- 构建并通过 `uv publish` 发布到 PyPI(使用 `PYPI_TOKEN` 机密)
48+
- 使用 `mkdocs gh-deploy` 更新 GitHub Pages 文档
49+
50+
无需在本地执行 `uv build``uv publish`;只需确保 Release 草稿信息完整(新增特性、兼容性注意事项等)。
51+
52+
## 其他注意事项
53+
54+
- Schema 有破坏性修改时,请同步更新 `tests/test_json_golden.py`、端到端用例(如 `tests/test_rpc.py`)以及相关示例。
55+
- 如果需要清理生成文件,可运行 `make clean`,之后重新执行 `make gen-all`
56+
- 发布前务必确认 `ACP_ENABLE_GEMINI_TESTS` 等可选测试在必要环境下运行通过,以避免 Release 后出现回归。

tests/test_rpc.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@
4040
AgentMessageChunk,
4141
AllowedOutcome,
4242
DeniedOutcome,
43+
PermissionOption,
4344
TextContentBlock,
45+
ToolCallLocation,
46+
ToolCallProgress,
47+
ToolCallStart,
48+
ToolCallUpdate,
4449
UserMessageChunk,
4550
)
4651

@@ -416,6 +421,169 @@ async def test_ignore_invalid_messages():
416421
await asyncio.wait_for(s.client_reader.readline(), timeout=0.1)
417422

418423

424+
class _ExampleAgent(Agent):
425+
__test__ = False
426+
427+
def __init__(self) -> None:
428+
self._conn: AgentSideConnection | None = None
429+
self.permission_response: RequestPermissionResponse | None = None
430+
self.prompt_requests: list[PromptRequest] = []
431+
432+
def bind(self, conn: AgentSideConnection) -> "_ExampleAgent":
433+
self._conn = conn
434+
return self
435+
436+
async def initialize(self, params: InitializeRequest) -> InitializeResponse:
437+
return InitializeResponse(protocolVersion=params.protocolVersion)
438+
439+
async def newSession(self, params: NewSessionRequest) -> NewSessionResponse:
440+
return NewSessionResponse(sessionId="sess_demo")
441+
442+
async def prompt(self, params: PromptRequest) -> PromptResponse:
443+
assert self._conn is not None
444+
self.prompt_requests.append(params)
445+
446+
await self._conn.sessionUpdate(
447+
SessionNotification(
448+
sessionId=params.sessionId,
449+
update=AgentMessageChunk(
450+
sessionUpdate="agent_message_chunk",
451+
content=TextContentBlock(type="text", text="I'll help you with that."),
452+
),
453+
)
454+
)
455+
456+
await self._conn.sessionUpdate(
457+
SessionNotification(
458+
sessionId=params.sessionId,
459+
update=ToolCallStart(
460+
sessionUpdate="tool_call",
461+
toolCallId="call_1",
462+
title="Modifying configuration",
463+
kind="edit",
464+
status="pending",
465+
locations=[ToolCallLocation(path="/project/config.json")],
466+
rawInput={"path": "/project/config.json"},
467+
),
468+
)
469+
)
470+
471+
permission_request = RequestPermissionRequest(
472+
sessionId=params.sessionId,
473+
toolCall=ToolCallUpdate(
474+
toolCallId="call_1",
475+
title="Modifying configuration",
476+
kind="edit",
477+
status="pending",
478+
locations=[ToolCallLocation(path="/project/config.json")],
479+
rawInput={"path": "/project/config.json"},
480+
),
481+
options=[
482+
PermissionOption(kind="allow_once", name="Allow", optionId="allow"),
483+
PermissionOption(kind="reject_once", name="Reject", optionId="reject"),
484+
],
485+
)
486+
response = await self._conn.requestPermission(permission_request)
487+
self.permission_response = response
488+
489+
if isinstance(response.outcome, AllowedOutcome) and response.outcome.optionId == "allow":
490+
await self._conn.sessionUpdate(
491+
SessionNotification(
492+
sessionId=params.sessionId,
493+
update=ToolCallProgress(
494+
sessionUpdate="tool_call_update",
495+
toolCallId="call_1",
496+
status="completed",
497+
rawOutput={"success": True},
498+
),
499+
)
500+
)
501+
await self._conn.sessionUpdate(
502+
SessionNotification(
503+
sessionId=params.sessionId,
504+
update=AgentMessageChunk(
505+
sessionUpdate="agent_message_chunk",
506+
content=TextContentBlock(type="text", text="Done."),
507+
),
508+
)
509+
)
510+
511+
return PromptResponse(stopReason="end_turn")
512+
513+
514+
class _ExampleClient(TestClient):
515+
__test__ = False
516+
517+
def __init__(self) -> None:
518+
super().__init__()
519+
self.permission_requests: list[RequestPermissionRequest] = []
520+
521+
async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse:
522+
self.permission_requests.append(params)
523+
if not params.options:
524+
return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
525+
option = params.options[0]
526+
return RequestPermissionResponse(outcome=AllowedOutcome(optionId=option.optionId, outcome="selected"))
527+
528+
529+
@pytest.mark.asyncio
530+
async def test_example_agent_permission_flow():
531+
async with _Server() as s:
532+
agent = _ExampleAgent()
533+
client = _ExampleClient()
534+
535+
agent_conn = ClientSideConnection(lambda _conn: client, s.client_writer, s.client_reader)
536+
AgentSideConnection(lambda conn: agent.bind(conn), s.server_writer, s.server_reader)
537+
538+
init = await agent_conn.initialize(InitializeRequest(protocolVersion=1))
539+
assert init.protocolVersion == 1
540+
541+
session = await agent_conn.newSession(NewSessionRequest(mcpServers=[], cwd="/workspace"))
542+
assert session.sessionId == "sess_demo"
543+
544+
prompt = PromptRequest(
545+
sessionId=session.sessionId,
546+
prompt=[TextContentBlock(type="text", text="Please edit config")],
547+
)
548+
resp = await agent_conn.prompt(prompt)
549+
assert resp.stopReason == "end_turn"
550+
551+
for _ in range(50):
552+
if len(client.notifications) >= 4:
553+
break
554+
await asyncio.sleep(0.02)
555+
556+
assert len(client.notifications) >= 4
557+
session_updates = [getattr(note.update, "sessionUpdate", None) for note in client.notifications]
558+
assert session_updates[:4] == ["agent_message_chunk", "tool_call", "tool_call_update", "agent_message_chunk"]
559+
560+
first_message = client.notifications[0].update
561+
assert isinstance(first_message, AgentMessageChunk)
562+
assert first_message.content.text == "I'll help you with that."
563+
564+
tool_call = client.notifications[1].update
565+
assert isinstance(tool_call, ToolCallStart)
566+
assert tool_call.title == "Modifying configuration"
567+
assert tool_call.status == "pending"
568+
569+
tool_update = client.notifications[2].update
570+
assert isinstance(tool_update, ToolCallProgress)
571+
assert tool_update.status == "completed"
572+
assert tool_update.rawOutput == {"success": True}
573+
574+
final_message = client.notifications[3].update
575+
assert isinstance(final_message, AgentMessageChunk)
576+
assert final_message.content.text == "Done."
577+
578+
assert len(client.permission_requests) == 1
579+
options = client.permission_requests[0].options
580+
assert [opt.optionId for opt in options] == ["allow", "reject"]
581+
582+
assert agent.permission_response is not None
583+
assert isinstance(agent.permission_response.outcome, AllowedOutcome)
584+
assert agent.permission_response.outcome.optionId == "allow"
585+
586+
419587
@pytest.mark.asyncio
420588
async def test_spawn_agent_process_roundtrip(tmp_path):
421589
script = Path(__file__).parents[1] / "examples" / "echo_agent.py"

0 commit comments

Comments
 (0)