diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index 6a675457f3..7c7b302d89 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -509,7 +509,7 @@ export function planBoundConversationRequest(params: JsonObject): JsonObject { return {pending: !returned && row.command === null && row.status === "accepted" && turn?.session_id === row.session_id && turn?.turn_id === row.turn_id && (expandedResult || metadataOwed || answerReviewOwed || unfinished || turn?.status === "failed" - && ["usage_limit_exceeded", "response_timeout", "upstream_transport_failure", + && ["usage_limit_exceeded", "response_timeout", "upstream_transport_failure", "context_window_exceeded", "antigravity_rpc_error", "antigravity_transport_error", "antigravity_step_error", "manager_authorization_unavailable", "runtime_error", "server_restarted"].includes(String(turn.error_code))) && !(current?.session_id === row.session_id && current?.status === "closed") @@ -1141,7 +1141,8 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre && Number(item.manager_context_version) <= version) .map(item => sessionIdentity(item.thread_id, "original native thread"))]; const priorNative = budget.native_observation as JsonObject | undefined; - const nativeThreads = [...new Set([...(threads.includes(priorNative?.thread_id) ? [priorNative!.thread_id] : []), ...threads])]; + const nativeThreads = [...new Set([...(typeof priorNative?.thread_id === "string" && threads.includes(priorNative.thread_id) + ? [priorNative.thread_id] : []), ...threads])]; const candidate = {...no, recovery_candidate: true, answer_review: true, read_only_review: true, client_turn_id: client, session_id: current.session_id, failed_turn_id: failed.turn_id, native_thread_ids: nativeThreads}; @@ -1158,7 +1159,7 @@ function planExhaustedStewardAnswer(recovery: JsonObject, row: JsonObject, curre || !Number.isFinite(now) || !Number.isFinite(ended) || now < ended + 60_000) return no; if (recovery.native_observation == null) return candidate; const proof = requireJsonObject(recovery.native_observation, "exhausted original request inventory"); - if (!nativeThreads.includes(proof.thread_id) || proof.turn_id !== failed.upstream_turn_id + if (typeof proof.thread_id !== "string" || !nativeThreads.includes(proof.thread_id) || proof.turn_id !== failed.upstream_turn_id || proof.only_original_user_item !== true || proof.codex_error_info !== "usageLimitExceeded") return candidate; const capacity = nativeRecoveryCapacity(recovery, row, current, candidate); return {...capacity, recovery_eligible: capacity.recovery_eligible === true && capacity.answer_executor === "codex"}; diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index 8ae6c6cffe..61311038f5 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -68,9 +68,10 @@ test("exhausted business quota permits one read-only answer, never a fresh busin {executor_configuration: {...configuration, configuration_revision: "changed"}}, {metadata_capacity: {alternate_executor: {...capacity.alternate_executor, observed_at: at - 121}}}, {metadata_capacity: {alternate_executor: {...capacity.alternate_executor, observed_at: at + 1}}}, - ...[{thread_id: "foreign"}, {turn_id: "foreign"}, {only_original_user_item: false}, + ...[{thread_id: "foreign"}, {thread_id: null}, {thread_id: 7}, {turn_id: "foreign"}, {only_original_user_item: false}, {codex_error_info: "other"}].map(value => ({native_observation: {...proof, ...value}})), ...[{delta_count: 1}, {response: {}}, {status: "running"}, {message: "changed"}, + {error_code: "context_window_exceeded"}, {client_turn_id: "uncorrelated"}].map(value => ({failed_turn: {...failed, ...value}}))]) { assert.notEqual(planBoundConversationRequest({...packet, context_recovery: {...packet.context_recovery, ...change}}).recovery_eligible, true); @@ -1447,7 +1448,7 @@ test("quota backlog projection retires explicit lifecycle stops, not unavailable } }); -for (const errorCode of ["response_timeout", "upstream_transport_failure", +for (const errorCode of ["response_timeout", "upstream_transport_failure", "context_window_exceeded", "antigravity_rpc_error", "antigravity_transport_error", "antigravity_step_error", "runtime_error", "server_restarted"]) { test(`delivered ${errorCode} notices retain the exact failed request, including other executors`, () => { const request = {request_ref: "f".repeat(24), command: null, status: "accepted", diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index 28de5303e1..08995cace5 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -1325,7 +1325,7 @@ def test_delivered_quota_failure_stays_pending_across_restart_without_replaying( @pytest.mark.parametrize("command", ["stop", "new"]) -@pytest.mark.parametrize("error_code", ["response_timeout", "upstream_transport_failure"]) +@pytest.mark.parametrize("error_code", ["response_timeout", "upstream_transport_failure", "context_window_exceeded"]) def test_delivered_timeout_stays_pending_without_replaying_and_honors_stop(ordinary, monkeypatch, command, error_code): # noqa: F811 from loopx.chat_agent import CodexChatAgentError from loopx.capabilities.native_chat.codex_adapter import CodexAppServerAdapter