|
16 | 16 | from loopx.extensions.lark.conversation_identity import identity_ref |
17 | 17 |
|
18 | 18 |
|
| 19 | +@pytest.mark.parametrize("hard_limit, native_error", [(None, True), (150.0, True), (None, False)]) |
| 20 | +def test_native_timeout_reports_its_clock_without_extending_native_retry( |
| 21 | + tmp_path, language_server, monkeypatch, hard_limit, native_error, |
| 22 | +): |
| 23 | + from test_antigravity_steward_adapter import _native_clock, adapter_for |
| 24 | + from loopx.chat_agent import CodexChatAgentError |
| 25 | + |
| 26 | + user = {"type": "CORTEX_STEP_TYPE_USER_INPUT", "status": "CORTEX_STEP_STATUS_DONE", |
| 27 | + "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": 0}}} |
| 28 | + steps, frames = [user], [] |
| 29 | + for idx, at in enumerate((1.0, 120.0, 240.0, 305.0), 1): |
| 30 | + if native_error: |
| 31 | + steps = [*steps, {"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_DONE", |
| 32 | + "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": idx}}, |
| 33 | + "errorMessage": {"error": {"shortError": f"EOF {idx} NATIVE_PRIVATE_SENTINEL /private/host"}}}] |
| 34 | + frames.append({"at": at, "steps": steps, "status": "CASCADE_RUN_STATUS_RUNNING"}) |
| 35 | + _native_clock(monkeypatch, language_server, frames) |
| 36 | + events = [] |
| 37 | + with pytest.raises(CodexChatAgentError) as raised: |
| 38 | + adapter_for(tmp_path, hard_timeout_sec=hard_limit).start_turn( |
| 39 | + "Original task", lambda kind, data: events.append((kind, data))) |
| 40 | + assert raised.value.error_code == "response_timeout" |
| 41 | + diagnostic = next(data["timeout"] for kind, data in events |
| 42 | + if kind == "agent.phase" and data.get("method") == "native/timeout") |
| 43 | + assert diagnostic["kind"] == ("hard" if hard_limit is not None else "idle") |
| 44 | + assert diagnostic["limit_seconds"] == (hard_limit or 300.0) |
| 45 | + assert diagnostic["native_error_count"] == ((3 if hard_limit else 4) if native_error else 0) |
| 46 | + assert raised.value.native_error_info == {"timeout": diagnostic} |
| 47 | + assert language_server["now"] == (240.0 if hard_limit else 305.0) |
| 48 | + assert language_server["cancelled"].is_set() |
| 49 | + assert [method for method, _ in language_server["calls"]].count("SendUserCascadeMessage") == 1 |
| 50 | + assert not any(kind in {"answer.final", "turn.native_result"} for kind, _ in events) |
| 51 | + serialized = json.dumps(events) |
| 52 | + assert "NATIVE_PRIVATE_SENTINEL" not in serialized and "/private/host" not in serialized |
| 53 | + errors = [data for kind, data in events if data.get("method") == "native/error"] |
| 54 | + assert len(errors) == diagnostic["native_error_count"] |
| 55 | + |
| 56 | + |
| 57 | +@pytest.mark.parametrize("kind, expected", [ |
| 58 | + ("idle", "等待原生助手有效进展超时"), ("hard", "原生助手达到本次执行时限"), |
| 59 | + ("unknown-host-value", "等待助手响应超时"), (None, "等待助手响应超时"), |
| 60 | +]) |
| 61 | +def test_original_lark_timeout_keeps_typed_reason_and_never_replays( |
| 62 | + steward, language_server, monkeypatch, kind, expected, |
| 63 | +): |
| 64 | + from loopx.capabilities.native_chat.codex_adapter import CodexAppServerAdapter |
| 65 | + from loopx.chat_agent import CodexChatAgentError |
| 66 | + from loopx.extensions.lark.private_conversations import LarkPrivateConversations |
| 67 | + |
| 68 | + starts = [] |
| 69 | + |
| 70 | + def fail_after_observed_progress(_adapter, message, event_sink): |
| 71 | + starts.append(message) |
| 72 | + event_sink("turn.started", {"upstream_turn_id": "synthetic-timeout"}) |
| 73 | + event_sink("agent.phase", {"method": "native/timeout", "label": "private label", |
| 74 | + "timeout": {"kind": kind, "native_error_count": 4, "raw": "NATIVE_PRIVATE_SENTINEL"}}) |
| 75 | + raise CodexChatAgentError("Private host diagnostic NATIVE_PRIVATE_SENTINEL", gate=None, |
| 76 | + error_code="response_timeout") |
| 77 | + |
| 78 | + monkeypatch.setattr(CodexAppServerAdapter, "start_turn", fail_after_observed_progress) |
| 79 | + store, runtime, provider, transport, binding, _, _ = steward |
| 80 | + native_route = kind in {"idle", "hard"} |
| 81 | + if native_route: |
| 82 | + from test_antigravity_steward_adapter import _native_clock, _native_frames |
| 83 | + monkeypatch.setenv("LOOPX_MANAGER_MODEL", "gemini-4-argon-high") |
| 84 | + monkeypatch.setenv("LOOPX_MANAGER_REASONING_EFFORT", "high") |
| 85 | + binding = transport.bindings.configure(transport_ref="steward-app", project_ref=binding["project_ref"], |
| 86 | + executor_endpoint_id="antigravity", context_kind="steward") |
| 87 | + runtime.hard_timeout_sec = 150.0 if kind == "hard" else None |
| 88 | + frames = _native_frames(stagnant=True) |
| 89 | + for index, frame in enumerate(frames): |
| 90 | + frame["steps"] += [{"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_DONE", |
| 91 | + "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": 2 + attempt}}, |
| 92 | + "errorMessage": {"error": {"shortError": "EOF NATIVE_PRIVATE_SENTINEL"}}} |
| 93 | + for attempt in range(index)] |
| 94 | + _native_clock(monkeypatch, language_server, frames) |
| 95 | + source = provider.event("steward-app", "native-timeout-source", "Complete this original request") |
| 96 | + transport.admit("steward-app", source) |
| 97 | + ref = identity_ref(binding["provider_ref"], source["message_id"]) |
| 98 | + row = transport.core.read_request(ref) |
| 99 | + failed = runtime.wait_for_turn(session_id=row["session_id"], turn_id=row["turn_id"], timeout_sec=8) |
| 100 | + assert failed["status"] == "failed" and failed["error_code"] == "response_timeout" |
| 101 | + original = store._turn_path(row["session_id"], row["turn_id"]).read_bytes() |
| 102 | + assert transport.reconcile() == 1 |
| 103 | + saved = json.loads((transport.root / f"{ref}.json").read_text()) |
| 104 | + terminal = saved["deliveries"]["terminal"] |
| 105 | + assert terminal["verified"] and expected in terminal["text"] |
| 106 | + assert ("期间原生助手记录了调用错误" in terminal["text"]) is (kind in {"idle", "hard"}) |
| 107 | + assert "不会自动重放" in terminal["text"] |
| 108 | + assert "NATIVE_PRIVATE_SENTINEL" not in terminal["text"] and "private label" not in terminal["text"] |
| 109 | + returned = provider.messages[terminal["attempt"]["message_ref"]] |
| 110 | + assert returned["chat_id"] == source["chat_id"] |
| 111 | + assert expected in json.dumps(returned["body"], ensure_ascii=False) |
| 112 | + restarted = LarkPrivateConversations(controller=runtime, runtime_root=transport.runtime_root, |
| 113 | + runner=provider, cli_bin="lark-cli") |
| 114 | + before = list(provider.writes) |
| 115 | + assert restarted.reconcile() == 0 |
| 116 | + assert restarted.health()[row["binding_id"]] == {"pending_count": 1, "recovery_count": 0} |
| 117 | + assert (not starts and [method for method, _ in language_server["calls"]].count("SendUserCascadeMessage") == 1 |
| 118 | + if native_route else len(starts) == 1) |
| 119 | + assert provider.writes == before |
| 120 | + assert store._turn_path(row["session_id"], row["turn_id"]).read_bytes() == original |
| 121 | + |
| 122 | + |
| 123 | +@pytest.mark.parametrize("info", [None, [], {"kind": []}, {"kind": {}}, {"kind": "idle", "native_error_count": True}]) |
| 124 | +def test_timeout_presentation_does_not_trust_unknown_diagnostic_text(info): |
| 125 | + from loopx.extensions.lark.manager_context import manager_failure_reply |
| 126 | + code, text = manager_failure_reply({"error_code": "response_timeout", "error": "NATIVE_PRIVATE_SENTINEL"}, |
| 127 | + phase_events=[{"kind": "agent.phase", "payload": {"method": "native/timeout", "timeout": info}}]) |
| 128 | + assert code == "processing_failed" and "超时" in text and "不会自动重放" in text |
| 129 | + assert "NATIVE_PRIVATE_SENTINEL" not in text and "期间原生助手记录了调用错误" not in text |
| 130 | + |
| 131 | + |
| 132 | +@pytest.mark.parametrize("source_code", ["response_timeout", "antigravity_step_error", "unknown"]) |
| 133 | +def test_native_error_labels_preserve_shared_admission_failure_category(source_code): |
| 134 | + from loopx.extensions.lark.manager_context import manager_failure_reply |
| 135 | + code, _ = manager_failure_reply({"error_code": source_code}) |
| 136 | + assert code == "processing_failed" |
| 137 | + |
| 138 | + |
19 | 139 | @pytest.mark.parametrize("fault, expected", [ |
20 | 140 | ("done", True), ("error", True), ("canceled", True), ("cleared", True), |
21 | 141 | ("running", False), ("generating", False), ("waiting", False), |
|
0 commit comments