Skip to content

Commit bc5bc65

Browse files
authored
refactor(test): decouple examples from integration harness (#2)
* test(support): make tests self-contained; rebuild integration off examples tests/support 承载断言层+框架+逐字场景+桩+finish_session×2+finish_dream;test_examples→test_scenarios_offline 改测 tests/support(safe_error 两分支保留);新建 tests/integration;testpaths/marker/addopts。examples 未改。 Task: 1789906941 * docs(examples): slim to pure runnable samples, drop test harness 移除断言层(TurnResult/wait_reply/turn/ProjectMemory),保留运行时;场景改纯演示 print、无 verify;删 conftest 与两个 test_live。 Task: 1789906941 * docs(examples): add conversation/identity_config/custom_tools/streaming_deltas to match Go parity Task: 1789906941
1 parent f5b6946 commit bc5bc65

36 files changed

Lines changed: 2177 additions & 804 deletions

‎Makefile‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ build:
2424
$(PYTHON) -m build
2525

2626
test-live:
27-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples/forward -m live -v
27+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_forward.py -m integration -v
2828

2929
test-live-managed:
30-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples/managed -m live -v
30+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_managed.py -m integration -v
3131

3232
test-live-all:
33-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples -m live -v
33+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration -m integration -v

‎examples/common/live.py‎

Lines changed: 0 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import argparse
44
import json
55
import os
6-
import random
76
import re
87
import sys
98
import time
@@ -171,89 +170,6 @@ def cleanup(self) -> None:
171170
self.output("cleanup", "completed")
172171

173172

174-
@dataclass
175-
class TurnResult:
176-
text: str = ""
177-
last_id: str = ""
178-
tool_used: bool = False
179-
complete: bool = False
180-
181-
def observe(self, event: Any) -> None:
182-
if hasattr(event, "to_dict"):
183-
event = event.to_dict(mode="json")
184-
kind = event.get("type")
185-
if event.get("id"):
186-
self.last_id = event["id"]
187-
if kind in ("session.error", "session.status_terminated"):
188-
raise AssertionError(f"Execution failed: {kind}, event_id={self.last_id}")
189-
if kind in ("agent.tool_use", "agent.mcp_tool_use"):
190-
self.tool_used = True
191-
elif kind == "agent.message":
192-
# Only the latest completed assistant message can satisfy assertions.
193-
self.text = "\n".join(
194-
block.get("text", "") for block in event.get("content", []) if block.get("type") == "text"
195-
)
196-
elif kind == "session.status_idle":
197-
reason = event.get("stop_reason")
198-
reason = reason.get("type") if isinstance(reason, dict) else reason
199-
if reason not in (None, "", "end_turn", "stop_sequence"):
200-
raise AssertionError(f"Execution stopped early: {reason}, event_id={self.last_id}")
201-
self.complete = bool(self.text)
202-
203-
def verify(self, expected: list[str], require_tool: bool = False) -> None:
204-
if not self.complete:
205-
raise AssertionError(f"No idle state after assistant output; last_event_id={self.last_id}")
206-
if not all(value in self.text for value in expected):
207-
raise AssertionError(f"Assistant output is missing expected values; last_event_id={self.last_id}")
208-
if require_tool and not self.tool_used:
209-
raise AssertionError(f"No actual tool execution; last_event_id={self.last_id}")
210-
211-
212-
def wait_reply(events: Any, run: Run, session_id: str, after: str = "") -> TurnResult:
213-
result = TurnResult(last_id=after)
214-
while not result.complete:
215-
run.remaining()
216-
page = events.list(
217-
session_id,
218-
order="asc",
219-
limit=100,
220-
extra_query={"after_id": result.last_id or None, "include_tool_calls": True},
221-
timeout=min(run.remaining(), 30),
222-
)
223-
for index, event in enumerate(page):
224-
if index >= 2000:
225-
raise AssertionError("Event polling exceeded 2000 events")
226-
result.observe(event)
227-
if result.complete:
228-
break
229-
if not result.complete:
230-
run.pause()
231-
run.output("assistant", result.text)
232-
return result
233-
234-
235-
def turn(
236-
events: Any,
237-
run: Run,
238-
session_id: str,
239-
prompt: str,
240-
expected: list[str],
241-
*,
242-
require_tool: bool = False,
243-
) -> TurnResult:
244-
run.output("user", prompt)
245-
result = events.send(
246-
session_id,
247-
events=[{"type": "user.message", "content": [{"type": "text", "text": prompt}]}],
248-
extra_headers={"Idempotency-Key": name("event")},
249-
)
250-
if len(result.data) != 1 or not result.data[0].id:
251-
raise AssertionError("Send must return exactly one user event ID")
252-
reply = wait_reply(events, run, session_id, result.data[0].id)
253-
reply.verify(expected, require_tool)
254-
return reply
255-
256-
257173
def choose_model(models: Any, requested: str) -> str:
258174
enabled = [model.id for model in models.data if model.is_enabled and model.id]
259175
if requested:
@@ -265,29 +181,6 @@ def choose_model(models: Any, requested: str) -> str:
265181
return "ultimate" if "ultimate" in enabled else sorted(enabled)[0]
266182

267183

268-
@dataclass
269-
class ProjectMemory:
270-
project: str = field(default_factory=lambda: "青禾订单-" + marker()[:6])
271-
release_time: str = field(default_factory=lambda: f"{random.randrange(20, 24):02}:{random.randrange(60):02}")
272-
contact: str = field(default_factory=lambda: random.choice(["林岚", "陈朔", "叶澄", "苏棠"]))
273-
rollback_version: str = field(
274-
default_factory=lambda: f"v2.{random.randrange(100, 1000)}.{random.randrange(100, 1000)}"
275-
)
276-
path = "projects/release-conventions.md"
277-
278-
def content(self) -> str:
279-
return f"---\nname: release-conventions\ndescription: {self.project} 的项目发布约定\nmetadata:\n type: project\n---\n\n# {self.project}\n\n- 北京时间 {self.release_time} 开始发布。\n- 发布异常时联系值班负责人{self.contact}。\n- 回滚使用已验证的稳定版本 {self.rollback_version}。\n\nWhy: 在值班窗口发布,并使用验证过的版本恢复服务。\nHow to apply: 为这个项目拟定发布计划时遵循以上约定。\n"
280-
281-
def index(self) -> str:
282-
return f"- [{self.project} 发布约定]({self.path}) — 发布窗口、异常联系人与回滚约定。\n"
283-
284-
def prompt(self) -> str:
285-
return f"请根据你记得的项目约定,为「{self.project}」拟一份简短上线安排,涵盖开始时间、异常联系和回滚处理。不要执行发布;缺少信息时请明确说明。"
286-
287-
def expected(self) -> list[str]:
288-
return [self.release_time, self.contact, self.rollback_version]
289-
290-
291184
def run_cli(mode: str, client_type: Any, scenarios: dict[str, Callable[[Any, Run], None]]) -> None:
292185
parser = argparse.ArgumentParser(description=f"Qoder {mode} SDK examples")
293186
parser.add_argument("--env", default=".env.live")

‎examples/forward/__main__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@
44
from qca import Forward
55

66
from .batch import run as batch
7+
from .conversation import run as conversation
8+
from .identity_config import run as identity_config
79
from .memory import run as memory
810
from .models import run as models
911
from .resources import run as resources
1012
from .schedule import run as schedule
1113
from .session import run as session
14+
from .streaming_deltas import run as streaming_deltas
1215

1316
SCENARIOS = {
1417
"models": models,
1518
"session": session,
19+
"conversation": conversation,
1620
"resources": resources,
21+
"identity_config": identity_config,
1722
"memory": memory,
1823
"schedule": schedule,
1924
"batch": batch,
25+
"streaming_deltas": streaming_deltas,
2026
}
2127

2228

‎examples/forward/batch.py‎

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""上传 JSONL 批量任务,等待完成并核对任务、输出与会话回复。
1+
"""上传 JSONL 批量任务,等待完成并打印任务状态与输出。
22
33
运行:python -m examples.forward.batch
44
"""
@@ -10,7 +10,7 @@
1010

1111
import httpx
1212

13-
from examples.common.live import Run, choose_model, marker, name, run_cli, wait_reply
13+
from examples.common.live import Run, choose_model, name, run_cli
1414
from qca import Forward
1515

1616
from ._cleanup import finish_session
@@ -34,12 +34,12 @@ def run(client: Forward, context: Run) -> None:
3434
)
3535
template_id = context.track("template", template.id, lambda: client.templates.archive(template.id))
3636

37-
expected, custom_id = marker(), name("task")
37+
custom_id = name("task")
3838
data = {
3939
"custom_id": custom_id,
4040
"template_id": template_id,
4141
"identity_id": identity_id,
42-
"body": {"input": "Reply with exactly " + expected},
42+
"body": {"input": "请用一句话打个招呼。"},
4343
}
4444
input_file = client.files.upload(
4545
file=("input.jsonl", (json.dumps(data) + "\n").encode()), purpose="session_resource"
@@ -60,60 +60,32 @@ def cleanup_batch() -> None:
6060
while current.status not in terminal:
6161
context.pause()
6262
current = client.batches.retrieve(batch.id)
63-
if not current.output_file_id:
64-
if current.request_counts and current.request_counts.total == 0:
65-
return
66-
raise AssertionError("Batch has no output for session cleanup")
67-
rows = batch_rows(client, batch.id)
68-
if (
69-
len(rows) != 1
70-
or rows[0].get("custom_id") != custom_id
71-
or rows[0].get("identity_id") != identity_id
72-
or rows[0].get("template_id") != template_id
73-
):
74-
raise AssertionError("Batch cleanup output does not match this run")
75-
if rows[0].get("session_id"):
76-
finish_session(client, context, rows[0]["session_id"])
63+
if current.output_file_id:
64+
for row in batch_rows(client, batch.id):
65+
if row.get("session_id"):
66+
finish_session(client, context, row["session_id"])
7767

7868
context.track("batch", batch.id, cleanup_batch)
7969
while batch.status not in terminal:
8070
context.pause()
8171
batch = client.batches.retrieve(batch.id)
82-
if (
83-
batch.status != "completed"
84-
or not batch.request_counts
85-
or batch.request_counts.completed != 1
86-
or batch.request_counts.failed != 0
87-
or not batch.output_file_id
88-
):
89-
raise AssertionError("Batch did not complete exactly one successful task")
72+
context.output("batch_status", batch.status)
73+
if batch.request_counts:
74+
context.output(
75+
"request_counts",
76+
{"completed": batch.request_counts.completed, "failed": batch.request_counts.failed},
77+
)
9078
tasks = client.batches.tasks.list(batch.id)
91-
if len(tasks.data) != 1 or tasks.data[0].custom_id != custom_id:
92-
raise AssertionError("Batch task did not round trip")
93-
rows = batch_rows(client, batch.id)
94-
if len(rows) != 1:
95-
raise AssertionError("Expected one Batch output row")
96-
row = rows[0]
97-
context.output("batch_output", row)
98-
if (
99-
row.get("custom_id") != custom_id
100-
or row.get("identity_id") != identity_id
101-
or row.get("template_id") != template_id
102-
or row.get("status") != "completed"
103-
or row.get("error")
104-
or not row.get("session_id")
105-
):
106-
raise AssertionError("Batch output ownership or status mismatch")
107-
if expected not in json.dumps(row.get("response")):
108-
raise AssertionError("Batch response does not contain expected output")
109-
wait_reply(client.sessions.events, context, row["session_id"]).verify([expected])
79+
context.output("tasks", [task.custom_id for task in tasks.data])
80+
if batch.output_file_id:
81+
context.output("batch_output", batch_rows(client, batch.id))
11082

11183

11284
def batch_rows(client: Forward, batch_id: str) -> list[dict[str, Any]]:
11385
link = client.batches.retrieve_output(batch_id)
11486
url = httpx.URL(link.url)
11587
if url.scheme not in ("http", "https") or not url.host or url.userinfo:
116-
raise AssertionError("Invalid Batch output URL")
88+
raise RuntimeError("Invalid Batch output URL")
11789
# A separate HTTP client prevents API credentials from reaching storage.
11890
with httpx.Client(timeout=30, follow_redirects=True) as download:
11991
with download.stream("GET", url) as response:
@@ -122,7 +94,7 @@ def batch_rows(client: Forward, batch_id: str) -> list[dict[str, Any]]:
12294
for chunk in response.iter_bytes():
12395
content.extend(chunk)
12496
if len(content) > 4 * 1024 * 1024:
125-
raise AssertionError("Batch output exceeds the example's 4 MiB limit")
97+
raise RuntimeError("Batch output exceeds the example's 4 MiB limit")
12698
return [json.loads(line) for line in content.splitlines() if line.strip()]
12799

128100

‎examples/forward/conversation.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""复用同一个 Session 进行多轮对话,并分页读取会话历史。
2+
3+
运行:python -m examples.forward.conversation
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from examples.common.live import Run, choose_model, marker, name, run_cli
9+
from qca import Forward
10+
11+
from ._cleanup import finish_session
12+
13+
14+
def run(client: Forward, context: Run) -> None:
15+
environment = client.environments.create(name=name("env"), config={"type": "cloud"})
16+
environment_id = context.track("environment", environment.id, lambda: client.environments.archive(environment.id))
17+
18+
identity = client.identities.create(external_id=name("identity"), name="SDK 示例用户")
19+
identity_id = context.track("identity", identity.id, lambda: client.identities.delete(identity.id))
20+
21+
model = choose_model(client.models.list(), context.config.model)
22+
context.output("selected_model", model)
23+
template = client.templates.create(
24+
name=name("template"),
25+
environment_id=environment_id,
26+
model=model,
27+
system="你是一个 SDK 示例助手。必要时调用工具,只使用可实际读取的数据回答问题。",
28+
tools=[{"type": "agent_toolset_20260401"}],
29+
)
30+
template_id = context.track("template", template.id, lambda: client.templates.archive(template.id))
31+
32+
session = client.sessions.create(identity_id=identity_id, template_id=template_id)
33+
session_id = context.track("session", session.id, lambda: finish_session(client, context, session.id))
34+
35+
def ask(prompt: str) -> None:
36+
context.output("user", prompt)
37+
sent = client.sessions.events.send(
38+
session_id,
39+
events=[{"type": "user.message", "content": [{"type": "text", "text": prompt}]}],
40+
extra_headers={"Idempotency-Key": name("event")},
41+
)
42+
if not sent.data or not sent.data[0].id:
43+
raise RuntimeError("Send returned no user event")
44+
with client.sessions.events.stream(
45+
session_id,
46+
extra_headers={"Last-Event-ID": sent.data[0].id},
47+
timeout=context.remaining(),
48+
) as stream:
49+
for event in stream:
50+
context.remaining()
51+
if event.type == "agent.message":
52+
context.output("assistant", event.to_json())
53+
elif event.type in ("session.error", "session.status_terminated"):
54+
raise RuntimeError(f"Session stopped: {event.type}")
55+
elif event.type == "session.status_idle":
56+
break
57+
58+
# 同一个 Session 支持多轮:服务端在 session_id 下保留完整历史,无需客户端携带上文。
59+
code = "project-" + marker()
60+
ask(f"这次项目代号是 {code}。请在本次对话中记住它,不要使用工具或写入记忆库。现在只回复:已记住。")
61+
ask("只根据本次会话上文,告诉我刚才约定的项目代号。只回复代号,不要使用工具。")
62+
63+
# 分页读取已有的用户消息和助手回复,重建对话文字记录。
64+
for event in client.sessions.events.list(session_id, order="asc", limit=100):
65+
if event.type in ("user.message", "agent.message"):
66+
context.output(f"history.{event.type}", event.to_json())
67+
68+
69+
if __name__ == "__main__":
70+
run_cli("forward", Forward, {"conversation": run})

0 commit comments

Comments
 (0)