From 09dc4c884124f474da14e6ea3c4ea17bd615184a Mon Sep 17 00:00:00 2001 From: Rene Arredondo <120709323+Rene0422@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:41:44 -0700 Subject: [PATCH] fix(agent): return session_id when chat completion produces no events (#15169) (#15228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #15169 — `POST /api/v1/agents/chat/completions` returned `data: {}` with no `session_id` when the agent produced no events (e.g. the reporter's payload sent `"query": ""`). ## Root cause For `{"agent_id": "...", "query": "", "stream": false}`: 1. No `session_id` in the request → new-session branch at `agent_api.py:1278`. 2. `session_id = get_uuid()` at `agent_api.py:1294`. 3. Falls into `_run_workflow_session`. 4. `canvas.run(query="")` produces no events, so `final_ans` stays `{}`. 5. Non-streaming path then hit: ```python if not final_ans: await commit_runtime_replica() return get_result(data={}) ``` `session_id` was allocated but silently dropped on the way out. The streaming path had the same shape (only a bare `[DONE]` was yielded — no SSE event carrying `session_id`). The session-continuation path at `agent_api.py:1463` had the same bug for callers that passed `session_id` and got `{}` back. The successful (non-empty) paths were fine because every canvas event has `ans["session_id"] = session_id` attached before being yielded / captured into `final_ans` (see `agent_api.py:255` and `:303`). ## Fix Three minimal changes, all in `api/apps/restful_apis/agent_api.py`: 1. **`_run_workflow_session` (non-streaming)**: `return get_result(data={"session_id": session_id})` instead of `data={}`. 2. **`_run_workflow_session` (SSE)**: if the canvas loop emits no events, yield one `data:{"session_id": "...", "data": {}}` event before `[DONE]`, so the client receives the id over the wire. 3. **`agent_chat_completion` session-continuation**: echo the caller-supplied `session_id` back in the empty-events case instead of `{}`. No change needed on the happy paths — they already attach `session_id` to every event. ## Test plan - [ ] Repro from the issue: `POST /api/v1/agents/chat/completions` with `{"agent_id": "", "query": "", "stream": false}`. Response `data` should now contain `session_id`. - [ ] Same payload with `"stream": true`. SSE stream should contain one event with `session_id` before `data:[DONE]`. - [ ] Same shape but with a real, non-empty `"query"` (new session). Response should be unchanged from before — every event still carries `session_id`, final response still includes it on `final_ans`. - [ ] Pass an existing `session_id` plus `"query": ""`. Response should echo that `session_id` back instead of `{}`. - [ ] Pass an existing `session_id` plus a normal query. Response should be unchanged from before. - [ ] `openai-compatible: true` path is untouched — sanity-check it still works. - [ ] Run `uv run pytest` to make sure no existing tests regress. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) - [ ] New Feature (non-breaking change which adds functionality) - [ ] Documentation Update - [ ] Refactoring - [ ] Performance Improvement - [ ] Other (please describe): --- api/apps/restful_apis/agent_api.py | 50 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) 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"] = {}