diff --git a/api/apps/restful_apis/agent_api.py b/api/apps/restful_apis/agent_api.py index 2f20928523..2bd157b77f 100644 --- a/api/apps/restful_apis/agent_api.py +++ b/api/apps/restful_apis/agent_api.py @@ -323,6 +323,20 @@ async def _run_workflow_session( final_ans["data"]["structured"] = structured_output if trace_items: final_ans["data"]["trace"] = trace_items + else: + # Canvas produced no events (e.g. empty query). Still + # surface the session_id so the client can resume the + # conversation — without it the SSE stream is just a + # bare [DONE] (fixes #15169). + logging.info( + "empty agent output - returning session_id (agent_id=%s session_id=%s stream=%s)", + agent_id, session_id, True, + ) + yield ( + "data:" + + json.dumps({"session_id": session_id, "data": {}}, ensure_ascii=False) + + "\n\n" + ) await persist_workflow_session() except Exception as exc: logging.exception(exc) @@ -366,8 +380,16 @@ async def _run_workflow_session( return get_result(data=f"**ERROR**: {str(exc)}") if not final_ans: + # Canvas produced no events (e.g. caller sent an empty query). The + # API contract still promises a session_id back so the client can + # resume the conversation — return it instead of an empty dict + # (fixes #15169). + logging.info( + "empty agent output - returning session_id (agent_id=%s session_id=%s stream=%s)", + agent_id, session_id, False, + ) await commit_runtime_replica() - return get_result(data={}) + return get_result(data={"session_id": session_id}) if "data" not in final_ans or not isinstance(final_ans["data"], dict): final_ans["data"] = {} @@ -1549,8 +1571,24 @@ async def agent_chat_completion(tenant_id, agent_id=None): if req.get("stream", True): async def generate(): + emitted = False async for ans in _iter_session_completion_events(tenant_id, agent_id, req, return_trace): + emitted = True yield "data:" + json.dumps(ans, ensure_ascii=False) + "\n\n" + if not emitted: + # Parity with the new-session SSE path: if the canvas yields + # no events on an existing session (e.g. empty query), still + # echo the session_id so clients can recover it instead of + # seeing only a bare [DONE] (fixes #15169). + logging.info( + "empty agent output - returning session_id (agent_id=%s session_id=%s stream=%s)", + agent_id, session_id, True, + ) + yield ( + "data:" + + json.dumps({"session_id": session_id, "data": {}}, ensure_ascii=False) + + "\n\n" + ) yield "data:[DONE]\n\n" return _build_sse_response(generate()) @@ -1587,7 +1625,15 @@ async def agent_chat_completion(tenant_id, agent_id=None): return get_result(data=f"**ERROR**: {str(exc)}") if not final_ans: - return get_result(data={}) + # Same contract as the new-session path: even when the canvas + # emits nothing (e.g. empty query against an existing session), + # echo the session_id back so the client can keep using it + # (fixes #15169). + logging.info( + "empty agent output - returning session_id (agent_id=%s session_id=%s stream=%s)", + agent_id, session_id, False, + ) + return get_result(data={"session_id": session_id}) if "data" not in final_ans or not isinstance(final_ans["data"], dict): final_ans["data"] = {}