diff --git a/backend/app/api/chat_sessions.py b/backend/app/api/chat_sessions.py index 31f3eb148..5f83a741d 100644 --- a/backend/app/api/chat_sessions.py +++ b/backend/app/api/chat_sessions.py @@ -35,6 +35,7 @@ ) from app.services.agent_runtime.tool_execution import ( ToolExecutionError, + is_user_reconcilable_unknown_execution, reconcile_unknown_tool_execution, ) from app.services.participant_identity import get_or_create_user_participant @@ -547,11 +548,7 @@ async def get_session_runtime_state( and isinstance(execution.result_metadata.get("error_code"), str) else None ), - can_reconcile=( - execution.tool_name == "write_file" - and execution.effect == "write" - and execution.retry_policy == "conditional" - ), + can_reconcile=is_user_reconcilable_unknown_execution(execution), ) for execution in pending_reconciliations ], diff --git a/backend/app/services/agent_runtime/tool_execution.py b/backend/app/services/agent_runtime/tool_execution.py index 4020a708e..46310aa19 100644 --- a/backend/app/services/agent_runtime/tool_execution.py +++ b/backend/app/services/agent_runtime/tool_execution.py @@ -37,6 +37,18 @@ RetryPolicy = Literal["safe", "conditional", "never"] SAFE_READ_MAX_ATTEMPTS = 3 +# These tools dispatch an external image-generation request and can therefore +# leave the provider outcome uncertain after a response timeout. Direct Chat +# offers an explicit human confirmation before allowing the Run to continue. +_IMAGE_GENERATION_TOOL_NAMES = frozenset( + { + "generate_image_siliconflow", + "generate_image_openai", + "generate_image_google", + "generate_image_custom", + } +) + _PERSISTED_STATUSES = frozenset({"started", "succeeded", "failed", "unknown"}) _SIDE_EFFECT_CLASSIFICATIONS = frozenset({"read", "write", "external_write"}) _RETRY_POLICIES = frozenset({"safe", "conditional", "never"}) @@ -1856,15 +1868,10 @@ async def reconcile_unknown_tool_execution( "tool_execution_scope_mismatch", "tool execution does not belong to the requested run", ) - effect, retry_policy = _execution_metadata(execution) - if ( - execution.tool_name != "write_file" - or effect != "write" - or retry_policy != "conditional" - ): + if not is_user_reconcilable_unknown_execution(execution): raise ToolExecutionError( "tool_execution_reconciliation_not_supported", - "manual reconciliation is only supported for conditional write_file receipts", + "manual reconciliation is only supported for conditional write_file or image-generation receipts", ) prior_metadata = ( @@ -1921,3 +1928,22 @@ async def reconcile_unknown_tool_execution( execution.completed_at = reconciled_at await db.flush() return execution + + +def is_user_reconcilable_unknown_execution(execution: AgentToolExecution) -> bool: + """Return whether Direct Chat can safely settle this unknown receipt. + + The user must explicitly decide whether a dispatched operation took effect. + A ``not_applied`` decision closes only the old receipt; any retry remains a + new tool call, so the original provider request is never replayed. + """ + effect, retry_policy = _execution_metadata(execution) + return ( + execution.tool_name == "write_file" + and effect == "write" + and retry_policy == "conditional" + ) or ( + execution.tool_name in _IMAGE_GENERATION_TOOL_NAMES + and effect == "external_write" + and retry_policy == "never" + ) diff --git a/backend/tests/test_chat_session_runtime_state.py b/backend/tests/test_chat_session_runtime_state.py index 9644c1000..c0bfb67c7 100644 --- a/backend/tests/test_chat_session_runtime_state.py +++ b/backend/tests/test_chat_session_runtime_state.py @@ -238,6 +238,59 @@ async def test_runtime_state_exposes_unknown_write_and_blocks_plain_resume() -> assert pending.can_reconcile is True +@pytest.mark.asyncio +async def test_runtime_state_exposes_unknown_image_generation_for_user_confirmation() -> None: + agent, user, session, run = _records() + reader = SimpleNamespace(get_run_state=AsyncMock(return_value=_view(run))) + execution = AgentToolExecution( + id=uuid.uuid4(), + tenant_id=run.tenant_id, + run_id=run.id, + tool_call_id="call-image-1", + tool_name="generate_image_openai", + assistant_message_id="assistant-1", + arguments_hash="hash", + sanitized_arguments={}, + effect="external_write", + retry_policy="never", + attempt_count=1, + status="unknown", + result_summary="The image generation outcome is unknown.", + result_metadata={"error_code": "image_generation_outcome_unknown"}, + started_at=run.created_at, + completed_at=run.updated_at, + ) + db = _Session( + _Result(scalar=session), + _Result(values=[run]), + _Result(scalar=None), + _Result(values=[execution]), + _Result(scalar=None), + ) + + with ( + patch( + "app.api.chat_sessions.check_agent_access", + new=AsyncMock(return_value=(agent, None)), + ), + patch( + "app.api.chat_sessions._open_run_state_reader", + return_value=_ReaderContext(reader), + ), + ): + response = await get_session_runtime_state( + agent.id, + session.id, + current_user=user, + db=db, # type: ignore[arg-type] + ) + + assert response.active_run is not None + assert response.active_run.can_resume is False + assert response.active_run.pending_tool_reconciliations[0].tool_name == "generate_image_openai" + assert response.active_run.pending_tool_reconciliations[0].can_reconcile is True + + @pytest.mark.asyncio async def test_runtime_state_disables_resume_and_cancel_while_cancel_is_inflight() -> None: agent, user, session, run = _records() diff --git a/backend/tests/test_tool_execution.py b/backend/tests/test_tool_execution.py index cf362276c..107810153 100644 --- a/backend/tests/test_tool_execution.py +++ b/backend/tests/test_tool_execution.py @@ -181,9 +181,19 @@ def _sql(statement) -> str: ("succeeded", "externally_confirmed_applied"), ], ) -async def test_unknown_conditional_write_can_be_reconciled_by_user( +@pytest.mark.parametrize( + ("tool_name", "effect", "retry_policy"), + [ + ("write_file", "write", "conditional"), + ("generate_image_openai", "external_write", "never"), + ], +) +async def test_user_reconcilable_unknown_receipt_can_be_settled( confirmed_status: str, expected_error_code: str, + tool_name: str, + effect: str, + retry_policy: str, ) -> None: tenant_id = uuid.uuid4() run_id = uuid.uuid4() @@ -192,10 +202,10 @@ async def test_unknown_conditional_write_can_be_reconciled_by_user( tenant_id=tenant_id, run_id=run_id, status="unknown", - effect="write", - retry_policy="conditional", + effect=effect, + retry_policy=retry_policy, ) - execution.tool_name = "write_file" + execution.tool_name = tool_name execution.completed_at = _NOW db = _FakeSession(execution) @@ -234,7 +244,7 @@ async def test_unknown_reconciliation_rejects_unsupported_tool() -> None: with pytest.raises( tool_execution.ToolExecutionError, - match="only supported for conditional write_file", + match="only supported for conditional write_file or image-generation", ): await tool_execution.reconcile_unknown_tool_execution( db, # type: ignore[arg-type]