Merge observed master checkpoint to resolve Python smoke pairing conflict
This commit is contained in:
commit
3ca9334868
2054 files changed
+34974
-82416
No files matched your search
@@ -105,6 +105,11 @@ RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn."
|
||||
RESTART_SECOND_TEXT = "PROCESS_TWO_OK"
|
||||
RESTART_FIRST_SESSION_ID = "process-one"
|
||||
RESTART_SECOND_SESSION_ID = "process-two"
|
||||
RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call."
|
||||
RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools."
|
||||
RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK"
|
||||
RECOVERY_SESSION_ID = "scheduler-recovery"
|
||||
RECOVERY_CALL_IDS = ("scheduler-complete", "scheduler-fail", "scheduler-unstarted")
|
||||
SNAPSHOT_WORKFLOW_SCRIPT = (
|
||||
"phase('Delegate')\n"
|
||||
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
|
||||
@@ -136,6 +141,10 @@ RESTART_SNAPSHOT_DIRECTORY = (
|
||||
RESTART_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "requests.json", "session.1.v3.jsonl", "session.2.v3.jsonl",
|
||||
)
|
||||
RECOVERY_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "scheduler-recovery"
|
||||
)
|
||||
RECOVERY_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.v4.jsonl")
|
||||
MCP_SERVER_SCRIPT = """\
|
||||
import json
|
||||
import os
|
||||
@@ -316,6 +325,32 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
recovery_prompt = next((
|
||||
block.get("text")
|
||||
for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
and block.get("text") in {RECOVERY_PROMPT, RECOVERY_CONTINUE_PROMPT}
|
||||
), None)
|
||||
if recovery_prompt == RECOVERY_PROMPT:
|
||||
assert_advertised_tool(body, "todo_write")
|
||||
chunks = [message_start()]
|
||||
for index, call_id in enumerate(RECOVERY_CALL_IDS):
|
||||
call = tool_call_chunks(call_id, "todo_write", {"todos": []})
|
||||
chunks.extend({**chunk, "index": index} for chunk in call[1:-2])
|
||||
chunks.extend(call[-2:])
|
||||
return chunks
|
||||
if recovery_prompt == RECOVERY_CONTINUE_PROMPT:
|
||||
results = [
|
||||
block for message in messages
|
||||
for block in message.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
]
|
||||
if [result.get("tool_use_id") for result in results] != list(RECOVERY_CALL_IDS):
|
||||
raise AssertionError(f"recovered history lost tool-result pairing: {results}")
|
||||
if [result.get("is_error", False) for result in results] != [False, True, True]:
|
||||
raise AssertionError(f"recovered history changed completed or pending outcomes: {results}")
|
||||
return text_chunks(RECOVERY_TEXT)
|
||||
|
||||
tool_results = [
|
||||
block for block in latest.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result"
|
||||
@@ -743,7 +778,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
@@ -762,10 +797,10 @@ def main() -> None:
|
||||
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
|
||||
if args.installed_wheel:
|
||||
args.exe = assert_installed_wheel_environment()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-restart, sdk-authoring, or all")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, dynamic-tools, fs-search, spawn-node, snapshot, recovery, restart, office, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-dynamic-tools", "sdk-snapshot", "sdk-recovery", "sdk-restart", "sdk-authoring"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-dynamic-tools, sdk-snapshot, sdk-recovery, sdk-restart, sdk-authoring, or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
@@ -817,6 +852,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-snapshot", "sdk-recovery"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_scheduler_recovery(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "sdk-restart"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
@@ -1640,6 +1678,94 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
)
|
||||
|
||||
|
||||
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
first_request = len(MockModelHandler.requests)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-scheduler-recovery-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
dsh_home = root / "home"
|
||||
sessions = dsh_home / "sessions"
|
||||
base_patch = write_advanced_profile_patch(root, "base.patch.yml", sessions)
|
||||
patch = write_profile_patch(root, "recovery.patch.yml", sessions, [
|
||||
{"id": "tool-todo", "disabled": False},
|
||||
{"id": "session-title-llm", "disabled": True},
|
||||
{"id": "session-log-deepseek", "config": {"enabled": False}},
|
||||
{"insert": [{
|
||||
"id": "scheduler-failure-fixture",
|
||||
"name": (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs"
|
||||
).as_uri(),
|
||||
}]},
|
||||
])
|
||||
|
||||
def connect() -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
provider="deepseek-official", model="smoke-model", cwd=str(root),
|
||||
dsh_bin=str(executable), dsh_home=str(dsh_home),
|
||||
patches=(str(base_patch), str(patch)),
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60,
|
||||
)
|
||||
|
||||
with connect() as harness:
|
||||
failed = harness.run(RECOVERY_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
continued = harness.run(RECOVERY_CONTINUE_PROMPT, session_id=RECOVERY_SESSION_ID)
|
||||
results = [failed, continued]
|
||||
if [result.finish_reason for result in results] != ["error", "completed"]:
|
||||
outcomes = [
|
||||
event["data"]["reason"] for result in results
|
||||
for event in result.events if event.get("type") == "turn/end"
|
||||
]
|
||||
raise AssertionError(f"scheduler recovery did not preserve turn outcomes: {outcomes}")
|
||||
if continued.final_response != RECOVERY_TEXT:
|
||||
raise AssertionError("scheduler recovery lost the continued response")
|
||||
requests = MockModelHandler.requests[first_request:]
|
||||
if len(requests) != 2:
|
||||
raise AssertionError(f"scheduler recovery expected two serialized model requests: {requests}")
|
||||
logs = read_session_logs(sessions)
|
||||
records = logs[RECOVERY_SESSION_ID]
|
||||
calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"]
|
||||
if calls != list(RECOVERY_CALL_IDS[:2]):
|
||||
raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}")
|
||||
tool_results = [
|
||||
record["data"]["message"]
|
||||
for record in records if record.get("type") == "tool/result"
|
||||
]
|
||||
if [(message.get("role"), message.get("toolCallId"), message.get("isError", False)) for message in tool_results] != [
|
||||
("tool", RECOVERY_CALL_IDS[0], False), ("tool", RECOVERY_CALL_IDS[1], True), ("tool", RECOVERY_CALL_IDS[2], True),
|
||||
]:
|
||||
raise AssertionError(f"scheduler recovery duplicated or lost tool outcomes: {tool_results}")
|
||||
if sum(record.get("type") == "todo/write" for record in records) != 1:
|
||||
raise AssertionError("scheduler recovery executed an unstarted todo update")
|
||||
replacements = [(str(root), "{{cwd}}"), (RECOVERY_SESSION_ID, "{{parent}}")]
|
||||
result_value = [{
|
||||
"session_id": result.session_id,
|
||||
"final_response": result.final_response,
|
||||
"finish_reason": result.finish_reason,
|
||||
"eventTypes": [event.get("type") for event in result.events],
|
||||
"notificationMethods": [notification.method for notification in result.notifications],
|
||||
} for result in results]
|
||||
request_value = [{
|
||||
"model": request.get("model"),
|
||||
"messages": restart_request_messages(request),
|
||||
} for request in requests]
|
||||
normalized_records = project_session_snapshot([
|
||||
normalize_snapshot_value(record, replacements) for record in records
|
||||
])
|
||||
content = render_jsonl(normalized_records)
|
||||
files = {
|
||||
"result.json": json.dumps(normalize_snapshot_value(result_value, replacements), indent=2) + "\n",
|
||||
"requests.json": json.dumps(normalize_snapshot_value(request_value, replacements), indent=2) + "\n",
|
||||
snapshot_session_filename(0, session_header_version(content, "scheduler recovery")): content,
|
||||
}
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, RECOVERY_SNAPSHOT_DIRECTORY, RECOVERY_SNAPSHOT_FILENAMES,
|
||||
)
|
||||
|
||||
|
||||
def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
@@ -2432,7 +2558,19 @@ def normalize_snapshot_value(
|
||||
if isinstance(dt, list):
|
||||
member["dt"] = [0] * len(dt)
|
||||
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool", "developer"):
|
||||
if not normalized["id"].startswith("{{message:"):
|
||||
# Replay fixtures retain deterministic repair ids with the call id and original event sequence.
|
||||
source = normalized.get("source")
|
||||
call_id = normalized.get("toolCallId") if normalized.get("role") == "tool" else (
|
||||
source.get("callId") if isinstance(source, dict) and source.get("kind") == "tool" else None
|
||||
)
|
||||
repair_prefix = (
|
||||
f"interrupted-tool-result-{call_id}-" if isinstance(call_id, str) else None
|
||||
)
|
||||
repair_id = (
|
||||
repair_prefix is not None and normalized["id"].startswith(repair_prefix)
|
||||
and normalized["id"][len(repair_prefix):].isdigit()
|
||||
)
|
||||
if not normalized["id"].startswith("{{message:") and not repair_id:
|
||||
normalized["id"] = "{{messageId}}"
|
||||
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
|
||||
data = normalized.get("data")
|
||||
|
||||
Reference in new issue
Block a user