fix(agent): return session_id when chat completion produces no events (#15169) (#15228)

## 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": "<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):
This commit is contained in:
Rene Arredondo
2026-06-30 01:41:44 -07:00
committed by GitHub
parent 3bb976b383
commit 09dc4c8841

View File

@@ -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"] = {}