-
-
0 && (
+
+ {awaitingCalls.map((tc) => (
+
- }
- />
-
-
- {(isToolCallsOpen || hasAwaitingApproval) && (
-
-
- {toolCalls.map((toolCall, index) => (
-
-
-
-
-
- Arguments
- {' '}
-
-
-
-
- {JSON.stringify(toolCall.arguments, null, 2)}
-
-
-
-
-
-
- Response
- {' '}
-
-
- {toolCall.status === 'pending' && (
-
-
-
- )}
- {toolCall.status === 'completed' && (
-
-
- {JSON.stringify(toolCall.result, null, 2)}
-
-
- )}
- {toolCall.status === 'error' && (
-
-
- {toolCall.error}
-
-
- )}
- {toolCall.status === 'awaiting_approval' && (
-
-
- This tool requires your approval before executing.
-
-
- setDenyComments((prev) => ({
- ...prev,
- [toolCall.call_id]: e.target.value,
- }))
- }
- />
-
-
-
-
-
- )}
- {toolCall.status === 'denied' && (
-
-
- Denied by user
-
-
- )}
-
-
-
- ))}
-
+ ))}
)}
+
+ {/* Regular tool calls accordion */}
+ {resolvedCalls.length > 0 && (
+ <>
+
+
+ }
+ />
+
+
+ {isToolCallsOpen && (
+
+
+ {resolvedCalls.map((toolCall, index) => (
+
+
+
+
+
+ Arguments
+ {' '}
+
+
+
+
+ {JSON.stringify(toolCall.arguments, null, 2)}
+
+
+
+
+
+
+ Response
+ {' '}
+
+
+ {toolCall.status === 'pending' && (
+
+
+
+ )}
+ {toolCall.status === 'completed' && (
+
+
+ {JSON.stringify(toolCall.result, null, 2)}
+
+
+ )}
+ {toolCall.status === 'error' && (
+
+
+ {toolCall.error}
+
+
+ )}
+ {toolCall.status === 'denied' && (
+
+
+ Denied by user
+
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+ >
+ )}
);
}
diff --git a/tests/integration/run_all.py b/tests/integration/run_all.py
index 12397a84..b8137b27 100644
--- a/tests/integration/run_all.py
+++ b/tests/integration/run_all.py
@@ -50,6 +50,7 @@ from tests.integration.test_analytics import AnalyticsTests
from tests.integration.test_connectors import ConnectorTests
from tests.integration.test_mcp import MCPTests
from tests.integration.test_misc import MiscTests
+from tests.integration.test_v1_api import V1ApiTests
# Module registry
@@ -64,6 +65,7 @@ MODULES = {
"connectors": ConnectorTests,
"mcp": MCPTests,
"misc": MiscTests,
+ "v1_api": V1ApiTests,
}
diff --git a/tests/integration/test_v1_api.py b/tests/integration/test_v1_api.py
new file mode 100644
index 00000000..9774af2b
--- /dev/null
+++ b/tests/integration/test_v1_api.py
@@ -0,0 +1,681 @@
+#!/usr/bin/env python3
+"""
+Integration tests for the /v1/ chat completions API (Phase 4).
+
+Endpoints tested:
+- /v1/chat/completions (POST) - Standard chat completions (streaming & non-streaming)
+- /v1/models (GET) - List available agent models
+
+Usage:
+ python tests/integration/test_v1_api.py
+ python tests/integration/test_v1_api.py --base-url http://localhost:7091
+ python tests/integration/test_v1_api.py --token YOUR_JWT_TOKEN
+"""
+
+import json as json_module
+import sys
+import time
+from pathlib import Path
+from typing import Optional
+
+import requests
+
+# Add parent directory to path for standalone execution
+_THIS_DIR = Path(__file__).parent
+_TESTS_DIR = _THIS_DIR.parent
+_ROOT_DIR = _TESTS_DIR.parent
+if str(_ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(_ROOT_DIR))
+
+from tests.integration.base import DocsGPTTestBase, create_client_from_args
+
+
+class V1ApiTests(DocsGPTTestBase):
+ """Integration tests for /v1/ chat completions API."""
+
+ # -------------------------------------------------------------------------
+ # Test Data Helpers
+ # -------------------------------------------------------------------------
+
+ def get_or_create_agent_key(self) -> Optional[str]:
+ """Get or create a test agent and return its API key."""
+ if hasattr(self, "_agent_key") and self._agent_key:
+ return self._agent_key
+
+ # Try both authenticated and unauthenticated creation.
+ # Published agents need a source to get an API key.
+ payload = {
+ "name": f"V1 Test Agent {int(time.time())}",
+ "description": "Integration test agent for v1 API tests",
+ "prompt_id": "default",
+ "chunks": 2,
+ "retriever": "classic",
+ "agent_type": "classic",
+ "status": "published",
+ "source": "default",
+ }
+
+ try:
+ response = self.post("/api/create_agent", json=payload, timeout=10)
+ if response.status_code in [200, 201]:
+ result = response.json()
+ api_key = result.get("key")
+ self._agent_id = result.get("id")
+ if api_key:
+ self._agent_key = api_key
+ self.print_info(f"Created test agent with key: {api_key[:8]}...")
+ return api_key
+ else:
+ self.print_warning("Agent created but no API key returned")
+ else:
+ self.print_warning(f"Agent creation returned {response.status_code}: {response.text[:200]}")
+ except Exception as e:
+ self.print_error(f"Failed to create agent: {e}")
+
+ return None
+
+ def _v1_headers(self, api_key: str) -> dict:
+ """Build headers for v1 API requests."""
+ return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+
+ # -------------------------------------------------------------------------
+ # /v1/chat/completions — Auth Tests
+ # -------------------------------------------------------------------------
+
+ def test_no_auth_returns_401(self) -> bool:
+ """Test that /v1/chat/completions without auth returns 401."""
+ test_name = "v1 chat completions - no auth"
+ self.print_header(f"Testing {test_name}")
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={"messages": [{"role": "user", "content": "Hi"}]},
+ headers={"Content-Type": "application/json"},
+ timeout=10,
+ )
+
+ if response.status_code == 401:
+ self.print_success("Correctly returned 401 for missing auth")
+ self.record_result(test_name, True, "401 as expected")
+ return True
+ else:
+ self.print_error(f"Expected 401, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+ except Exception as e:
+ self.print_error(f"Request failed: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ def test_invalid_key_returns_error(self) -> bool:
+ """Test that invalid API key returns error."""
+ test_name = "v1 chat completions - invalid key"
+ self.print_header(f"Testing {test_name}")
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={"messages": [{"role": "user", "content": "Hi"}]},
+ headers=self._v1_headers("invalid-key-12345"),
+ timeout=30,
+ )
+
+ # Should return 400 or 500 (agent not found)
+ if response.status_code in [400, 401, 500]:
+ self.print_success(f"Correctly returned {response.status_code} for invalid key")
+ self.record_result(test_name, True, f"Error as expected ({response.status_code})")
+ return True
+ else:
+ self.print_error(f"Unexpected status: {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+ except Exception as e:
+ self.print_error(f"Request failed: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ def test_missing_messages_returns_400(self) -> bool:
+ """Test that missing messages field returns 400."""
+ test_name = "v1 chat completions - missing messages"
+ self.print_header(f"Testing {test_name}")
+
+ api_key = self.get_or_create_agent_key()
+ if not api_key:
+ if not self.require_auth(test_name):
+ return True
+ self.record_result(test_name, True, "Skipped (no agent)")
+ return True
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={"stream": False},
+ headers=self._v1_headers(api_key),
+ timeout=10,
+ )
+
+ if response.status_code == 400:
+ self.print_success("Correctly returned 400 for missing messages")
+ self.record_result(test_name, True, "400 as expected")
+ return True
+ else:
+ self.print_error(f"Expected 400, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+ except Exception as e:
+ self.print_error(f"Request failed: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # /v1/chat/completions — Non-streaming
+ # -------------------------------------------------------------------------
+
+ def test_non_streaming_basic(self) -> bool:
+ """Test basic non-streaming chat completion."""
+ test_name = "v1 chat completions - non-streaming"
+ self.print_header(f"Testing {test_name}")
+
+ api_key = self.get_or_create_agent_key()
+ if not api_key:
+ if not self.require_auth(test_name):
+ return True
+ self.record_result(test_name, True, "Skipped (no agent)")
+ return True
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "Say hello in one word."}],
+ "stream": False,
+ },
+ headers=self._v1_headers(api_key),
+ timeout=60,
+ )
+
+ self.print_info(f"Status: {response.status_code}")
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.print_error(f"Response: {response.text[:300]}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ data = response.json()
+
+ # Verify standard format
+ checks = [
+ ("id" in data, "has id"),
+ (data.get("object") == "chat.completion", "object is chat.completion"),
+ ("choices" in data, "has choices"),
+ (len(data["choices"]) > 0, "choices not empty"),
+ (data["choices"][0].get("message", {}).get("role") == "assistant", "role is assistant"),
+ (data["choices"][0].get("message", {}).get("content") is not None, "has content"),
+ (data["choices"][0].get("finish_reason") == "stop", "finish_reason is stop"),
+ ("usage" in data, "has usage"),
+ ]
+
+ all_passed = True
+ for check, label in checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ content = data["choices"][0]["message"]["content"]
+ self.print_info(f"Response: {content[:100]}")
+
+ # Check docsgpt extension
+ if "docsgpt" in data:
+ self.print_success(" has docsgpt extension")
+ if "conversation_id" in data["docsgpt"]:
+ self.print_success(f" conversation_id: {data['docsgpt']['conversation_id'][:8]}...")
+
+ self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
+ return all_passed
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # /v1/chat/completions — Streaming
+ # -------------------------------------------------------------------------
+
+ def test_streaming_basic(self) -> bool:
+ """Test basic streaming chat completion."""
+ test_name = "v1 chat completions - streaming"
+ self.print_header(f"Testing {test_name}")
+
+ api_key = self.get_or_create_agent_key()
+ if not api_key:
+ if not self.require_auth(test_name):
+ return True
+ self.record_result(test_name, True, "Skipped (no agent)")
+ return True
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "Say hi briefly."}],
+ "stream": True,
+ },
+ headers=self._v1_headers(api_key),
+ stream=True,
+ timeout=60,
+ )
+
+ self.print_info(f"Status: {response.status_code}")
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ chunks = []
+ content_pieces = []
+ got_done = False
+ got_stop = False
+ got_id = False
+
+ for line in response.iter_lines():
+ if not line:
+ continue
+ line_str = line.decode("utf-8")
+ if not line_str.startswith("data: "):
+ continue
+
+ data_str = line_str[6:]
+ if data_str.strip() == "[DONE]":
+ got_done = True
+ break
+
+ try:
+ chunk = json_module.loads(data_str)
+ chunks.append(chunk)
+
+ # Standard chunks
+ if "choices" in chunk:
+ delta = chunk["choices"][0].get("delta", {})
+ if "content" in delta:
+ content_pieces.append(delta["content"])
+ if chunk["choices"][0].get("finish_reason") == "stop":
+ got_stop = True
+
+ # Extension chunks
+ if "docsgpt" in chunk:
+ ext = chunk["docsgpt"]
+ if ext.get("type") == "id":
+ got_id = True
+
+ except json_module.JSONDecodeError:
+ pass
+
+ full_content = "".join(content_pieces)
+
+ checks = [
+ (len(chunks) > 0, f"received {len(chunks)} chunks"),
+ (len(content_pieces) > 0, f"got content: {full_content[:50]}..."),
+ (got_stop, "got finish_reason=stop"),
+ (got_done, "got [DONE] sentinel"),
+ ]
+
+ all_passed = True
+ for check, label in checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ if got_id:
+ self.print_success(" got conversation_id via docsgpt extension")
+
+ self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
+ return all_passed
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # /v1/chat/completions — Multi-turn conversation
+ # -------------------------------------------------------------------------
+
+ def test_multi_turn_conversation(self) -> bool:
+ """Test multi-turn conversation with history in messages."""
+ test_name = "v1 chat completions - multi-turn"
+ self.print_header(f"Testing {test_name}")
+
+ api_key = self.get_or_create_agent_key()
+ if not api_key:
+ if not self.require_auth(test_name):
+ return True
+ self.record_result(test_name, True, "Skipped (no agent)")
+ return True
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/v1/chat/completions",
+ json={
+ "messages": [
+ {"role": "user", "content": "My name is TestBot."},
+ {"role": "assistant", "content": "Hello TestBot!"},
+ {"role": "user", "content": "What is my name?"},
+ ],
+ "stream": False,
+ },
+ headers=self._v1_headers(api_key),
+ timeout=60,
+ )
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ data = response.json()
+ content = data["choices"][0]["message"]["content"]
+ self.print_info(f"Response: {content[:150]}")
+
+ # The response should reference "TestBot" from the history
+ has_content = bool(content)
+ self.print_success(f" Got response with {len(content)} chars")
+ self.record_result(test_name, has_content, "Multi-turn works")
+ return has_content
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # /v1/models
+ # -------------------------------------------------------------------------
+
+ def test_list_models(self) -> bool:
+ """Test GET /v1/models endpoint."""
+ test_name = "v1 models - list"
+ self.print_header(f"Testing {test_name}")
+
+ api_key = self.get_or_create_agent_key()
+ if not api_key:
+ if not self.require_auth(test_name):
+ return True
+ self.record_result(test_name, True, "Skipped (no agent)")
+ return True
+
+ try:
+ response = requests.get(
+ f"{self.base_url}/v1/models",
+ headers=self._v1_headers(api_key),
+ timeout=10,
+ )
+
+ self.print_info(f"Status: {response.status_code}")
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ data = response.json()
+
+ checks = [
+ (data.get("object") == "list", "object is list"),
+ ("data" in data, "has data array"),
+ (len(data.get("data", [])) > 0, f"has {len(data.get('data', []))} model(s)"),
+ ]
+
+ all_passed = True
+ for check, label in checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ if data.get("data"):
+ model = data["data"][0]
+ model_checks = [
+ ("id" in model, "model has id"),
+ (model.get("object") == "model", "model object is 'model'"),
+ (model.get("owned_by") == "docsgpt", "owned_by is docsgpt"),
+ ]
+ for check, label in model_checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ self.record_result(test_name, all_passed, "All checks passed" if all_passed else "Some checks failed")
+ return all_passed
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ def test_models_no_auth(self) -> bool:
+ """Test that /v1/models without auth returns 401."""
+ test_name = "v1 models - no auth"
+ self.print_header(f"Testing {test_name}")
+
+ try:
+ response = requests.get(
+ f"{self.base_url}/v1/models",
+ timeout=10,
+ )
+
+ if response.status_code == 401:
+ self.print_success("Correctly returned 401")
+ self.record_result(test_name, True, "401 as expected")
+ return True
+ else:
+ self.print_error(f"Expected 401, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # Backward Compatibility — old endpoints still work
+ # -------------------------------------------------------------------------
+
+ def test_old_stream_endpoint_still_works(self) -> bool:
+ """Verify the old /stream endpoint still works after v1 changes."""
+ test_name = "Backward compat - /stream"
+ self.print_header(f"Testing {test_name}")
+
+ payload = {
+ "question": "Say hello briefly.",
+ "history": "[]",
+ "isNoneDoc": True,
+ }
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/stream",
+ json=payload,
+ headers=self.headers,
+ stream=True,
+ timeout=60,
+ )
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ events = []
+ got_end = False
+ got_answer = False
+
+ for line in response.iter_lines():
+ if line:
+ line_str = line.decode("utf-8")
+ if line_str.startswith("data: "):
+ try:
+ data = json_module.loads(line_str[6:])
+ events.append(data)
+ if data.get("type") == "answer":
+ got_answer = True
+ if data.get("type") == "end":
+ got_end = True
+ break
+ except json_module.JSONDecodeError:
+ pass
+
+ checks = [
+ (len(events) > 0, f"received {len(events)} events"),
+ (got_answer, "got answer event"),
+ (got_end, "got end event"),
+ ]
+
+ all_passed = True
+ for check, label in checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ self.record_result(test_name, all_passed, "Old endpoint works" if all_passed else "Regression")
+ return all_passed
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ def test_old_answer_endpoint_still_works(self) -> bool:
+ """Verify the old /api/answer endpoint still works."""
+ test_name = "Backward compat - /api/answer"
+ self.print_header(f"Testing {test_name}")
+
+ payload = {
+ "question": "Say hi.",
+ "history": "[]",
+ "isNoneDoc": True,
+ }
+
+ try:
+ response = requests.post(
+ f"{self.base_url}/api/answer",
+ json=payload,
+ headers=self.headers,
+ timeout=60,
+ )
+
+ if response.status_code != 200:
+ self.print_error(f"Expected 200, got {response.status_code}")
+ self.record_result(test_name, False, f"Status {response.status_code}")
+ return False
+
+ data = response.json()
+ checks = [
+ ("answer" in data, "has answer"),
+ ("conversation_id" in data, "has conversation_id"),
+ ]
+
+ all_passed = True
+ for check, label in checks:
+ if check:
+ self.print_success(f" {label}")
+ else:
+ self.print_error(f" {label}")
+ all_passed = False
+
+ self.print_info(f"Answer: {data.get('answer', '')[:100]}")
+ self.record_result(test_name, all_passed, "Old endpoint works" if all_passed else "Regression")
+ return all_passed
+
+ except Exception as e:
+ self.print_error(f"Error: {e}")
+ self.record_result(test_name, False, str(e))
+ return False
+
+ # -------------------------------------------------------------------------
+ # Cleanup
+ # -------------------------------------------------------------------------
+
+ def cleanup(self):
+ """Clean up test resources."""
+ if hasattr(self, "_agent_id") and self._agent_id and self.is_authenticated:
+ try:
+ self.post(f"/api/delete_agent?id={self._agent_id}", json={})
+ self.print_info(f"Cleaned up test agent {self._agent_id[:8]}...")
+ except Exception:
+ pass
+
+ # -------------------------------------------------------------------------
+ # Run All
+ # -------------------------------------------------------------------------
+
+ def run_all(self) -> bool:
+ """Run all v1 API integration tests."""
+ self.print_header("V1 Chat Completions API Integration Tests")
+ self.print_info(f"Base URL: {self.base_url}")
+ self.print_info(f"Authentication: {'Yes' if self.is_authenticated else 'No'}")
+
+ try:
+ # Auth tests (no agent needed)
+ self.test_no_auth_returns_401()
+ time.sleep(0.5)
+
+ self.test_models_no_auth()
+ time.sleep(0.5)
+
+ self.test_invalid_key_returns_error()
+ time.sleep(0.5)
+
+ self.test_missing_messages_returns_400()
+ time.sleep(0.5)
+
+ # Non-streaming
+ self.test_non_streaming_basic()
+ time.sleep(1)
+
+ # Streaming
+ self.test_streaming_basic()
+ time.sleep(1)
+
+ # Multi-turn
+ self.test_multi_turn_conversation()
+ time.sleep(1)
+
+ # Models
+ self.test_list_models()
+ time.sleep(0.5)
+
+ # Backward compatibility
+ self.test_old_stream_endpoint_still_works()
+ time.sleep(1)
+
+ self.test_old_answer_endpoint_still_works()
+ time.sleep(1)
+
+ finally:
+ self.cleanup()
+
+ return self.print_summary()
+
+
+def main():
+ """Main entry point."""
+ client = create_client_from_args(V1ApiTests, "DocsGPT V1 API Integration Tests")
+ success = client.run_all()
+ sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+ main()
From 398f3acc8dd9aaa2ef65afd30594bda27cc9514e Mon Sep 17 00:00:00 2001
From: Alex
Date: Wed, 1 Apr 2026 13:01:02 +0100
Subject: [PATCH 06/11] fix: clean error
---
application/api/v1/routes.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/application/api/v1/routes.py b/application/api/v1/routes.py
index d23e8c50..df7930d6 100644
--- a/application/api/v1/routes.py
+++ b/application/api/v1/routes.py
@@ -152,7 +152,7 @@ def chat_completions():
extra={"error": str(e)},
)
return make_response(
- jsonify({"error": {"message": str(e), "type": "invalid_request"}}),
+ jsonify({"error": {"message": "Failed to process request", "type": "invalid_request"}}),
400,
)
except Exception as e:
From 8b9e595d85462ebf40ef33abaf76724a050bfe09 Mon Sep 17 00:00:00 2001
From: Alex
Date: Wed, 1 Apr 2026 14:58:44 +0100
Subject: [PATCH 07/11] fix: structure improvements of messages
---
application/agents/base.py | 37 +++++++-----
application/api/answer/routes/answer.py | 31 +++-------
application/api/answer/routes/base.py | 58 +++++++++++--------
application/api/v1/routes.py | 47 ++++++++-------
.../src/conversation/conversationHandlers.ts | 14 ++++-
tests/api/answer/routes/test_answer.py | 23 ++------
tests/api/answer/routes/test_base.py | 14 ++---
tests/api/answer/test_base_routes.py | 12 ++--
8 files changed, 117 insertions(+), 119 deletions(-)
diff --git a/application/agents/base.py b/application/agents/base.py
index 4fc53795..4576e98d 100644
--- a/application/agents/base.py
+++ b/application/agents/base.py
@@ -138,17 +138,12 @@ class BaseAgent(ABC):
actions_by_id = {a["call_id"]: a for a in tool_actions}
+ # Build a single assistant message containing all tool calls so
+ # the message history matches the format LLM providers expect
+ # (one assistant message with N tool_calls, followed by N tool results).
+ tc_objects: List[Dict[str, Any]] = []
for pending in pending_tool_calls:
call_id = pending["call_id"]
- action = actions_by_id.get(call_id)
- if not action:
- action = {
- "call_id": call_id,
- "decision": "denied",
- "comment": "No response provided",
- }
-
- # Build the assistant tool-call message in standard format
args = pending["arguments"]
args_str = (
json.dumps(args) if isinstance(args, dict) else (args or "{}")
@@ -163,11 +158,25 @@ class BaseAgent(ABC):
}
if pending.get("thought_signature"):
tc_obj["thought_signature"] = pending["thought_signature"]
- messages.append({
- "role": "assistant",
- "content": None,
- "tool_calls": [tc_obj],
- })
+ tc_objects.append(tc_obj)
+
+ messages.append({
+ "role": "assistant",
+ "content": None,
+ "tool_calls": tc_objects,
+ })
+
+ # Now process each pending call and append tool result messages
+ for pending in pending_tool_calls:
+ call_id = pending["call_id"]
+ args = pending["arguments"]
+ action = actions_by_id.get(call_id)
+ if not action:
+ action = {
+ "call_id": call_id,
+ "decision": "denied",
+ "comment": "No response provided",
+ }
if action.get("decision") == "approved":
# Execute the tool server-side
diff --git a/application/api/answer/routes/answer.py b/application/api/answer/routes/answer.py
index b6fe288a..5fa7199f 100644
--- a/application/api/answer/routes/answer.py
+++ b/application/api/answer/routes/answer.py
@@ -126,33 +126,18 @@ class AnswerResource(Resource, BaseAnswerResource):
stream_result = self.process_response_stream(stream)
- if len(stream_result) == 7:
- (
- conversation_id,
- response,
- sources,
- tool_calls,
- thought,
- error,
- extra_info,
- ) = stream_result
- else:
- conversation_id, response, sources, tool_calls, thought, error = (
- stream_result
- )
- extra_info = None
-
- if error:
- return make_response({"error": error}, 400)
+ if stream_result["error"]:
+ return make_response({"error": stream_result["error"]}, 400)
result = {
- "conversation_id": conversation_id,
- "answer": response,
- "sources": sources,
- "tool_calls": tool_calls,
- "thought": thought,
+ "conversation_id": stream_result["conversation_id"],
+ "answer": stream_result["answer"],
+ "sources": stream_result["sources"],
+ "tool_calls": stream_result["tool_calls"],
+ "thought": stream_result["thought"],
}
+ extra_info = stream_result.get("extra")
if extra_info:
result.update(extra_info)
except Exception as e:
diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py
index 74932d1d..4a152b2a 100644
--- a/application/api/answer/routes/base.py
+++ b/application/api/answer/routes/base.py
@@ -540,8 +540,13 @@ class BaseAnswerResource:
yield f"data: {data}\n\n"
return
- def process_response_stream(self, stream):
- """Process the stream response for non-streaming endpoint"""
+ def process_response_stream(self, stream) -> Dict[str, Any]:
+ """Process the stream response for non-streaming endpoint.
+
+ Returns:
+ Dict with keys: conversation_id, answer, sources, tool_calls,
+ thought, error, and optional extra.
+ """
conversation_id = ""
response_full = ""
source_log_docs = []
@@ -577,7 +582,14 @@ class BaseAnswerResource:
thought = event["thought"]
elif event["type"] == "error":
logger.error(f"Error from stream: {event['error']}")
- return None, None, None, None, event["error"], None
+ return {
+ "conversation_id": None,
+ "answer": None,
+ "sources": None,
+ "tool_calls": None,
+ "thought": None,
+ "error": event["error"],
+ }
elif event["type"] == "end":
stream_ended = True
except (json.JSONDecodeError, KeyError) as e:
@@ -585,30 +597,30 @@ class BaseAnswerResource:
continue
if not stream_ended:
logger.error("Stream ended unexpectedly without an 'end' event.")
- return None, None, None, None, "Stream ended unexpectedly", None
+ return {
+ "conversation_id": None,
+ "answer": None,
+ "sources": None,
+ "tool_calls": None,
+ "thought": None,
+ "error": "Stream ended unexpectedly",
+ }
+
+ result: Dict[str, Any] = {
+ "conversation_id": conversation_id,
+ "answer": response_full,
+ "sources": source_log_docs,
+ "tool_calls": tool_calls,
+ "thought": thought,
+ "error": None,
+ }
if pending_tool_calls is not None:
- return (
- conversation_id,
- response_full,
- source_log_docs,
- tool_calls,
- thought,
- None,
- {"pending_tool_calls": pending_tool_calls},
- )
-
- result = (
- conversation_id,
- response_full,
- source_log_docs,
- tool_calls,
- thought,
- None,
- )
+ result["extra"] = {"pending_tool_calls": pending_tool_calls}
if is_structured:
- result = result + ({"structured": True, "schema": schema_info},)
+ result["extra"] = {"structured": True, "schema": schema_info}
+
return result
def error_stream_generate(self, err_response):
diff --git a/application/api/v1/routes.py b/application/api/v1/routes.py
index df7930d6..d773d962 100644
--- a/application/api/v1/routes.py
+++ b/application/api/v1/routes.py
@@ -36,16 +36,21 @@ def _extract_bearer_token() -> Optional[str]:
return None
-def _get_model_name(api_key: str) -> str:
- """Look up agent name for display as model name."""
+def _lookup_agent(api_key: str) -> Optional[Dict]:
+ """Look up the agent document for this API key."""
try:
mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME]
- agent = db["agents"].find_one({"key": api_key})
- if agent:
- return agent.get("name", api_key)
+ return db["agents"].find_one({"key": api_key})
except Exception:
- pass
+ logger.warning("Failed to look up agent for API key", exc_info=True)
+ return None
+
+
+def _get_model_name(agent: Optional[Dict], api_key: str) -> str:
+ """Return agent name for display as model name."""
+ if agent:
+ return agent.get("name", api_key)
return api_key
@@ -72,7 +77,8 @@ def chat_completions():
)
is_stream = data.get("stream", False)
- model_name = _get_model_name(api_key)
+ agent_doc = _lookup_agent(api_key)
+ model_name = _get_model_name(agent_doc, api_key)
try:
internal_data = translate_request(data, api_key)
@@ -83,8 +89,10 @@ def chat_completions():
400,
)
- # Use the api_key as decoded token for agent auth
- decoded_token = {"sub": "api_key_user"}
+ # Link decoded_token to the agent's owner so continuation state,
+ # logs, and tool execution use the correct user identity.
+ agent_user = agent_doc.get("user") if agent_doc else None
+ decoded_token = {"sub": agent_user or "api_key_user"}
try:
processor = StreamProcessor(internal_data, decoded_token)
@@ -232,26 +240,21 @@ def _non_stream_response(
result = helper.process_response_stream(stream)
- if len(result) == 7:
- conversation_id, answer, sources, tool_calls, thought, error, extra = result
- else:
- conversation_id, answer, sources, tool_calls, thought, error = result
- extra = None
-
- if error:
+ if result["error"]:
return make_response(
- jsonify({"error": {"message": error, "type": "server_error"}}),
+ jsonify({"error": {"message": result["error"], "type": "server_error"}}),
500,
)
+ extra = result.get("extra")
pending = extra.get("pending_tool_calls") if isinstance(extra, dict) else None
response = translate_response(
- conversation_id=conversation_id,
- answer=answer or "",
- sources=sources,
- tool_calls=tool_calls,
- thought=thought or "",
+ conversation_id=result["conversation_id"],
+ answer=result["answer"] or "",
+ sources=result["sources"],
+ tool_calls=result["tool_calls"],
+ thought=result["thought"] or "",
model_name=model_name,
pending_tool_calls=pending,
)
diff --git a/frontend/src/conversation/conversationHandlers.ts b/frontend/src/conversation/conversationHandlers.ts
index c7fcdf44..759635b6 100644
--- a/frontend/src/conversation/conversationHandlers.ts
+++ b/frontend/src/conversation/conversationHandlers.ts
@@ -411,15 +411,23 @@ function translateV1ChunkToInternalEvents(
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
+ let parsedArgs: Record = {};
+ if (tc.function?.arguments) {
+ try {
+ parsedArgs = JSON.parse(tc.function.arguments);
+ } catch {
+ // Arguments may arrive as fragments during streaming;
+ // keep the raw string so downstream can accumulate it.
+ parsedArgs = { _raw: tc.function.arguments };
+ }
+ }
events.push({
type: 'tool_call',
data: {
call_id: tc.id,
action_name: tc.function?.name || '',
tool_name: tc.function?.name || '',
- arguments: tc.function?.arguments
- ? JSON.parse(tc.function.arguments)
- : {},
+ arguments: parsedArgs,
status: 'requires_client_execution',
},
});
diff --git a/tests/api/answer/routes/test_answer.py b/tests/api/answer/routes/test_answer.py
index 53f2525d..4e4b7a8b 100644
--- a/tests/api/answer/routes/test_answer.py
+++ b/tests/api/answer/routes/test_answer.py
@@ -73,7 +73,7 @@ class TestAnswerResourcePost:
),
), patch(
"application.api.answer.routes.answer.AnswerResource.process_response_stream",
- return_value=(conv_id, "Hello", [], [], "", None),
+ return_value={"conversation_id": conv_id, "answer": "Hello", "sources": [], "tool_calls": [], "thought": "", "error": None},
):
resp = answer_client.post(
"/api/answer",
@@ -129,7 +129,7 @@ class TestAnswerResourcePost:
return_value=iter([]),
), patch(
"application.api.answer.routes.answer.AnswerResource.process_response_stream",
- return_value=(None, None, None, None, None, "Stream error"),
+ return_value={"conversation_id": None, "answer": None, "sources": None, "tool_calls": None, "thought": None, "error": "Stream error"},
):
resp = answer_client.post(
"/api/answer",
@@ -173,15 +173,7 @@ class TestAnswerResourcePost:
return_value=iter([]),
), patch(
"application.api.answer.routes.answer.AnswerResource.process_response_stream",
- return_value=(
- conv_id,
- '{"key": "val"}',
- [],
- [],
- "",
- None,
- {"structured": True, "schema": {"type": "object"}},
- ),
+ return_value={"conversation_id": conv_id, "answer": '{"key": "val"}', "sources": [], "tool_calls": [], "thought": "", "error": None, "extra": {"structured": True, "schema": {"type": "object"}}},
):
resp = answer_client.post(
"/api/answer",
@@ -208,14 +200,7 @@ class TestAnswerResourcePost:
return_value=iter([]),
), patch(
"application.api.answer.routes.answer.AnswerResource.process_response_stream",
- return_value=(
- conv_id,
- "answer text",
- [{"title": "src"}],
- [{"tool": "t"}],
- "thinking...",
- None,
- ),
+ return_value={"conversation_id": conv_id, "answer": "answer text", "sources": [{"title": "src"}], "tool_calls": [{"tool": "t"}], "thought": "thinking...", "error": None},
):
resp = answer_client.post(
"/api/answer",
diff --git a/tests/api/answer/routes/test_base.py b/tests/api/answer/routes/test_base.py
index eee2ab31..95b7e423 100644
--- a/tests/api/answer/routes/test_base.py
+++ b/tests/api/answer/routes/test_base.py
@@ -481,10 +481,10 @@ class TestProcessResponseStream:
result = resource.process_response_stream(iter(stream))
- assert result[0] == conv_id
- assert result[1] == "Hello world"
- assert result[2] == [{"title": "doc1"}]
- assert result[5] is None
+ assert result["conversation_id"] == conv_id
+ assert result["answer"] == "Hello world"
+ assert result["sources"] == [{"title": "doc1"}]
+ assert result["error"] is None
def test_handles_stream_error(self, mock_mongo_db, flask_app):
import json
@@ -500,10 +500,8 @@ class TestProcessResponseStream:
result = resource.process_response_stream(iter(stream))
- assert len(result) == 6
- assert result[0] is None
- assert result[4] == "Test error"
- assert result[5] is None
+ assert result["conversation_id"] is None
+ assert result["error"] == "Test error"
def test_handles_malformed_stream_data(self, mock_mongo_db, flask_app):
from application.api.answer.routes.base import BaseAnswerResource
diff --git a/tests/api/answer/test_base_routes.py b/tests/api/answer/test_base_routes.py
index dc6a19a5..d47076ad 100644
--- a/tests/api/answer/test_base_routes.py
+++ b/tests/api/answer/test_base_routes.py
@@ -295,10 +295,8 @@ class TestProcessResponseStreamExtended:
f'data: {json.dumps({"type": "end"})}\n\n',
]
result = resource.process_response_stream(iter(stream))
- assert result[1] == "{}"
- # Structured output adds extra tuple element
- assert len(result) == 7
- assert result[6]["structured"] is True
+ assert result["answer"] == "{}"
+ assert result.get("extra", {}).get("structured") is True
def test_handles_tool_calls_event(self, mock_mongo_db, flask_app):
from application.api.answer.routes.base import BaseAnswerResource
@@ -312,7 +310,7 @@ class TestProcessResponseStreamExtended:
f'data: {json.dumps({"type": "end"})}\n\n',
]
result = resource.process_response_stream(iter(stream))
- assert result[3] == [{"name": "t1"}]
+ assert result["tool_calls"] == [{"name": "t1"}]
def test_incomplete_stream(self, mock_mongo_db, flask_app):
from application.api.answer.routes.base import BaseAnswerResource
@@ -323,7 +321,7 @@ class TestProcessResponseStreamExtended:
f'data: {json.dumps({"type": "answer", "answer": "partial"})}\n\n',
]
result = resource.process_response_stream(iter(stream))
- assert result[4] == "Stream ended unexpectedly"
+ assert result["error"] == "Stream ended unexpectedly"
def test_handles_thought_event(self, mock_mongo_db, flask_app):
from application.api.answer.routes.base import BaseAnswerResource
@@ -335,7 +333,7 @@ class TestProcessResponseStreamExtended:
f'data: {json.dumps({"type": "end"})}\n\n',
]
result = resource.process_response_stream(iter(stream))
- assert result[4] == "thinking..."
+ assert result["thought"] == "thinking..."
@pytest.mark.unit
From be7da983e7ca7d7acc56d7def846a3bd22ae4122 Mon Sep 17 00:00:00 2001
From: Alex
Date: Fri, 3 Apr 2026 10:36:48 +0100
Subject: [PATCH 08/11] fix: remove internal tools when creating tools and
better Approval gate UX
---
application/agents/tools/base.py | 2 ++
application/agents/tools/internal_search.py | 2 ++
application/agents/tools/think.py | 2 ++
application/agents/tools/tool_manager.py | 2 +-
.../src/conversation/ConversationBubble.tsx | 21 ++++++++++++++++---
5 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/application/agents/tools/base.py b/application/agents/tools/base.py
index fd7b4a85..dfe8c85d 100644
--- a/application/agents/tools/base.py
+++ b/application/agents/tools/base.py
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
class Tool(ABC):
+ internal: bool = False
+
@abstractmethod
def execute_action(self, action_name: str, **kwargs):
pass
diff --git a/application/agents/tools/internal_search.py b/application/agents/tools/internal_search.py
index 2cd7915b..78001bf5 100644
--- a/application/agents/tools/internal_search.py
+++ b/application/agents/tools/internal_search.py
@@ -20,6 +20,8 @@ class InternalSearchTool(Tool):
- list_files action: browse the file/folder structure
"""
+ internal = True
+
def __init__(self, config: Dict):
self.config = config
self.retrieved_docs: List[Dict] = []
diff --git a/application/agents/tools/think.py b/application/agents/tools/think.py
index 7c1fc2b6..24af553e 100644
--- a/application/agents/tools/think.py
+++ b/application/agents/tools/think.py
@@ -36,6 +36,8 @@ class ThinkTool(Tool):
The reasoning content is captured in tool_call data for transparency.
"""
+ internal = True
+
def __init__(self, config=None):
pass
diff --git a/application/agents/tools/tool_manager.py b/application/agents/tools/tool_manager.py
index 08ef30a4..41970eac 100644
--- a/application/agents/tools/tool_manager.py
+++ b/application/agents/tools/tool_manager.py
@@ -19,7 +19,7 @@ class ToolManager:
continue
module = importlib.import_module(f"application.agents.tools.{name}")
for member_name, obj in inspect.getmembers(module, inspect.isclass):
- if issubclass(obj, Tool) and obj is not Tool:
+ if issubclass(obj, Tool) and obj is not Tool and not obj.internal:
tool_config = self.config.get(name, {})
self.tools[name] = obj(tool_config)
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx
index 2f070789..41f81a42 100644
--- a/frontend/src/conversation/ConversationBubble.tsx
+++ b/frontend/src/conversation/ConversationBubble.tsx
@@ -928,13 +928,23 @@ function ToolCallApprovalBar({