fix(api): 统一错误 envelope 与会话元数据 (#1764)

* fix(api): standardize error envelopes and session metadata

* fix(session): update archive metadata on commit
This commit is contained in:
Qin Haojie
2026-04-28 13:59:32 +08:00
committed by GitHub
parent b49acf57b6
commit ac9f679a0b
19 changed files with 317 additions and 41 deletions
+5
View File
@@ -204,6 +204,11 @@ resource parsing or synchronous reindex failures, are returned as non-2xx respon
`status="error"` and an `error` object. Clients should not look for `result.status="error"` to
detect request failure.
Request validation failures, including malformed JSON, missing required fields, and invalid
parameter values, return HTTP `400` with `error.code="INVALID_ARGUMENT"`. The response never uses
FastAPI's raw `{"detail": ...}` error format; when field-level validation information is
available, it is exposed under `error.details.validation_errors`.
Python HTTP SDKs (`SyncHTTPClient` and `AsyncHTTPClient`) raise the corresponding
`OpenVikingError` subclass for this envelope. For example, `PROCESSING_ERROR` is raised as
`ProcessingError`.
+2 -1
View File
@@ -86,7 +86,7 @@ Search the web for current information.
}
result = client.add_skill(skill)
print(f"Added: {result['uri']}")
print(f"Added: {result['root_uri']}")
```
**HTTP API**
@@ -121,6 +121,7 @@ openviking add-skill ./my-skill/ [--wait]
"status": "ok",
"result": {
"status": "success",
"root_uri": "viking://agent/skills/search-web/",
"uri": "viking://agent/skills/search-web/",
"name": "search-web",
"auxiliary_files": 0
+9 -2
View File
@@ -130,7 +130,7 @@ Get session details. Returns NOT_FOUND when the session does not exist by defaul
```python
# Get existing session (raises NotFoundError if not found)
info = client.get_session("a1b2c3d4")
print(f"Messages: {info['message_count']}, Commits: {info['commit_count']}")
print(f"Live Messages: {info['message_count']}, Total Messages: {info.get('total_message_count', 'n/a')}, Commits: {info['commit_count']}")
# Get or create session
info = client.get_session("a1b2c3d4", auto_create=True)
@@ -163,6 +163,7 @@ openviking session get a1b2c3d4
"created_at": "2026-03-23T10:00:00+08:00",
"updated_at": "2026-03-23T11:30:00+08:00",
"message_count": 5,
"total_message_count": 20,
"commit_count": 3,
"memories_extracted": {
"profile": 1,
@@ -190,6 +191,11 @@ openviking session get a1b2c3d4
}
```
Notes:
- `message_count` is the number of current live, unarchived messages.
- `total_message_count` is the cumulative count of archived and current live messages when present. Sessions created before this field existed may omit it.
- `commit_count` and `last_commit_at` are updated when commit Phase 1 archives messages successfully; Phase 2 memory extraction progress is tracked through the returned task.
---
### get_session_context()
@@ -205,6 +211,7 @@ This endpoint returns:
Notes:
- `latest_archive_overview` becomes an empty string when no completed archive exists, or when the latest overview does not fit in the token budget.
- `token_budget` is applied to the assembled payload after active `messages`: `latest_archive_overview` has higher priority than `pre_archive_abstracts`, and older abstracts are dropped first when budget is tight.
- `token_budget` must be greater than or equal to `0`. `token_budget=0` is valid and disables archive payload inclusion, while active `messages` are still returned. Negative values are rejected as invalid arguments.
- Only archive content that is actually returned is counted toward `estimatedTokens` and `stats.archiveTokens`.
- Session commit generates an archive summary during Phase 2 for every non-empty archive attempt. Only archives with a completed `.done` marker are exposed here.
@@ -213,7 +220,7 @@ Notes:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| session_id | str | Yes | - | Session ID |
| token_budget | int | No | 128000 | Token budget for assembled archive payload after active `messages` |
| token_budget | int | No | 128000 | Non-negative token budget for assembled archive payload after active `messages` |
**Python SDK (Embedded / HTTP)**
+4
View File
@@ -202,6 +202,10 @@ HTTP 错误始终使用顶层错误 envelope。资源解析、同步 reindex 等
响应,顶层为 `status="error"`,并包含 `error` 对象。客户端不应通过
`result.status="error"` 判断请求失败。
请求校验失败,包括 JSON 格式错误、缺少必填字段和参数值非法,统一返回 HTTP `400`,
并使用 `error.code="INVALID_ARGUMENT"`。响应不会使用 FastAPI 原生的 `{"detail": ...}`
错误格式;当存在字段级校验信息时,会通过 `error.details.validation_errors` 返回。
Python HTTP SDK(`SyncHTTPClient` 和 `AsyncHTTPClient`)会把该 envelope 映射为对应的
`OpenVikingError` 子类。例如 `PROCESSING_ERROR` 会抛出 `ProcessingError`。
+2 -1
View File
@@ -86,7 +86,7 @@ Search the web for current information.
}
result = client.add_skill(skill)
print(f"Added: {result['uri']}")
print(f"Added: {result['root_uri']}")
```
**HTTP API**
@@ -121,6 +121,7 @@ openviking add-skill ./my-skill/ [--wait]
"status": "ok",
"result": {
"status": "success",
"root_uri": "viking://agent/skills/search-web/",
"uri": "viking://agent/skills/search-web/",
"name": "search-web",
"auxiliary_files": 0
+9 -2
View File
@@ -130,7 +130,7 @@ openviking session list
```python
# 获取已有会话(不存在时抛 NotFoundError)
info = client.get_session("a1b2c3d4")
print(f"Messages: {info['message_count']}, Commits: {info['commit_count']}")
print(f"Live Messages: {info['message_count']}, Total Messages: {info.get('total_message_count', 'n/a')}, Commits: {info['commit_count']}")
# 获取或创建会话
info = client.get_session("a1b2c3d4", auto_create=True)
@@ -163,6 +163,7 @@ openviking session get a1b2c3d4
"created_at": "2026-03-23T10:00:00+08:00",
"updated_at": "2026-03-23T11:30:00+08:00",
"message_count": 5,
"total_message_count": 20,
"commit_count": 3,
"memories_extracted": {
"profile": 1,
@@ -190,6 +191,11 @@ openviking session get a1b2c3d4
}
```
说明:
- `message_count` 表示当前 live session 中尚未归档的消息数。
- `total_message_count` 存在时表示已归档消息与当前 live 消息的累计总数;该字段引入前创建的旧 session 可能不返回。
- `commit_count` 和 `last_commit_at` 会在 commit Phase 1 成功归档消息后更新;Phase 2 记忆提取进度通过返回的 task 跟踪。
---
### get_session_context()
@@ -205,6 +211,7 @@ openviking session get a1b2c3d4
说明:
- 没有可用 completed archive,或最新 overview 超出 token budget 时,`latest_archive_overview` 返回空字符串。
- `token_budget` 会在 active `messages` 之后作用于 assembled archive payload:`latest_archive_overview` 优先级高于 `pre_archive_abstracts`,预算紧张时先淘汰最旧的 abstracts。
- `token_budget` 必须大于等于 `0`。`token_budget=0` 合法,表示不返回 archive payload,但仍会返回 active `messages`;负数会作为非法参数拒绝。
- 只有最终实际返回的 archive 内容,才会计入 `estimatedTokens` 和 `stats.archiveTokens`。
- 当前每次有消息的 session commit 都会在 Phase 2 生成 archive 摘要;只有带 `.done` 标记的 completed archive 才会被这里返回。
@@ -213,7 +220,7 @@ openviking session get a1b2c3d4
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| session_id | str | 是 | - | 会话 ID |
| token_budget | int | 否 | 128000 | active `messages` 之后留给 assembled archive payload 的 token 预算 |
| token_budget | int | 否 | 128000 | active `messages` 之后留给 assembled archive payload 的非负 token 预算 |
**Python SDK (Embedded / HTTP)**
+110
View File
@@ -9,8 +9,10 @@ from contextlib import asynccontextmanager
from typing import Callable, Optional
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from openviking.server.api_keys import APIKeyManager
from openviking.server.config import (
@@ -51,6 +53,76 @@ from openviking_cli.utils.logger import init_otel_log_handler_from_server_config
logger = get_logger(__name__)
def _format_error_location(loc: object) -> str:
if not isinstance(loc, (list, tuple)):
return "request"
parts = [str(part) for part in loc if part is not None]
return ".".join(parts) if parts else "request"
def _normalize_validation_error(error: object) -> dict:
if not isinstance(error, dict):
return {"loc": ["request"], "message": str(error), "type": "value_error"}
loc = error.get("loc", ["request"])
if not isinstance(loc, (list, tuple)):
loc = [loc]
return {
"loc": [str(part) for part in loc],
"message": str(error.get("msg") or "Invalid value"),
"type": str(error.get("type") or "value_error"),
}
def _validation_error_message(errors: list[dict]) -> str:
if not errors:
return "Invalid request parameters"
first = errors[0]
location = _format_error_location(first.get("loc"))
message = first.get("message") or "Invalid value"
return f"Invalid request parameters: {location}: {message}"
_FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE = {
400: "INVALID_ARGUMENT",
401: "UNAUTHENTICATED",
403: "PERMISSION_DENIED",
404: "NOT_FOUND",
409: "CONFLICT",
422: "INVALID_ARGUMENT",
429: "RESOURCE_EXHAUSTED",
502: "UNAVAILABLE",
503: "UNAVAILABLE",
504: "DEADLINE_EXCEEDED",
}
def _error_code_from_framework_http_status(status_code: int) -> str:
"""Best-effort envelope code for framework/proxy HTTPException fallbacks.
Business routes should raise OpenVikingError subclasses directly instead
of relying on this status-code conversion.
"""
if status_code in _FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE:
return _FRAMEWORK_HTTP_STATUS_TO_ERROR_CODE[status_code]
return "INTERNAL" if status_code >= 500 else "UNKNOWN"
def _message_from_http_detail(detail: object) -> str:
if isinstance(detail, str) and detail:
return detail
if isinstance(detail, list):
errors = [_normalize_validation_error(item) for item in detail]
return _validation_error_message(errors)
if isinstance(detail, dict):
for key in ("message", "detail", "error"):
value = detail.get(key)
if isinstance(value, str) and value:
return value
if detail:
return str(detail)
return "HTTP request failed"
def create_app(
config: Optional[ServerConfig] = None,
service: Optional[OpenVikingService] = None,
@@ -235,6 +307,44 @@ def create_app(
).model_dump(),
)
@app.exception_handler(RequestValidationError)
async def request_validation_error_handler(request: Request, exc: RequestValidationError):
errors = [_normalize_validation_error(error) for error in exc.errors()]
code = "INVALID_ARGUMENT"
return JSONResponse(
status_code=ERROR_CODE_TO_HTTP_STATUS[code],
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=_validation_error_message(errors),
details={"validation_errors": errors},
),
).model_dump(exclude_none=True),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
code = _error_code_from_framework_http_status(exc.status_code)
response_status = exc.status_code
if code != "UNKNOWN":
response_status = ERROR_CODE_TO_HTTP_STATUS.get(code, exc.status_code)
details = None
if exc.status_code != response_status:
details = {"original_http_status_code": exc.status_code}
return JSONResponse(
status_code=response_status,
headers=exc.headers,
content=Response(
status="error",
error=ErrorInfo(
code=code,
message=_message_from_http_detail(exc.detail),
details=details,
),
).model_dump(exclude_none=True),
)
# Catch-all for unhandled exceptions so clients always get JSON
@app.exception_handler(Exception)
async def general_error_handler(request: Request, exc: Exception):
+3 -3
View File
@@ -5,7 +5,7 @@
import math
from typing import Any, Dict, List, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from openviking.pyagfs.exceptions import AGFSClientError, AGFSNotFoundError
@@ -17,7 +17,7 @@ from openviking.server.models import Response
from openviking.server.telemetry import run_operation
from openviking.telemetry import TelemetryRequest
from openviking.utils.search_filters import merge_time_filter
from openviking_cli.exceptions import NotFoundError
from openviking_cli.exceptions import InvalidArgumentError, NotFoundError
def _sanitize_floats(obj: Any) -> Any:
@@ -55,7 +55,7 @@ def _resolve_search_filter(
time_field=time_field,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise InvalidArgumentError(str(exc)) from exc
class FindRequest(BaseModel):
+8
View File
@@ -13,6 +13,7 @@ from openviking.server.auth import get_request_context
from openviking.server.dependencies import get_service
from openviking.server.identity import AuthMode, RequestContext
from openviking.server.models import ErrorInfo, Response
from openviking.server.responses import error_response
router = APIRouter(prefix="/api/v1/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
@@ -185,6 +186,13 @@ async def get_session_context(
_ctx: RequestContext = Depends(get_request_context),
):
"""Get assembled session context."""
if token_budget < 0:
return error_response(
"INVALID_ARGUMENT",
"token_budget must be greater than or equal to 0",
details={"field": "token_budget", "value": token_budget},
)
service = get_service()
session = service.sessions.session(_ctx, session_id)
await session.load()
+7 -2
View File
@@ -9,12 +9,13 @@ endpoints to check completion, results, or errors.
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from openviking.server.auth import get_request_context
from openviking.server.identity import RequestContext
from openviking.server.models import Response
from openviking.service.task_tracker import get_task_tracker
from openviking_cli.exceptions import OpenVikingError
router = APIRouter(prefix="/api/v1", tags=["tasks"])
@@ -32,7 +33,11 @@ async def get_task(
owner_user_id=_ctx.user.user_id,
)
if not task:
raise HTTPException(status_code=404, detail="Task not found or expired")
raise OpenVikingError(
"Task not found or expired",
code="NOT_FOUND",
details={"resource": task_id, "type": "task"},
)
return Response(status="ok", result=task.to_dict())
+2
View File
@@ -437,6 +437,8 @@ class ResourceService:
ctx=ctx,
allow_local_path_resolution=allow_local_path_resolution,
)
if isinstance(result, dict) and "root_uri" not in result and result.get("uri"):
result["root_uri"] = result["uri"]
if wait:
wait_start = time.perf_counter()
+42 -13
View File
@@ -67,6 +67,7 @@ class SessionMeta:
participant_user_ids: List[str] = field(default_factory=list)
participant_agent_ids: List[str] = field(default_factory=list)
message_count: int = 0
total_message_count: Optional[int] = 0
commit_count: int = 0
memories_extracted: Dict[str, int] = field(
default_factory=lambda: {
@@ -96,7 +97,7 @@ class SessionMeta:
)
def to_dict(self) -> Dict[str, Any]:
return {
data = {
"session_id": self.session_id,
"created_at": self.created_at,
"updated_at": self.updated_at,
@@ -110,6 +111,9 @@ class SessionMeta:
"llm_token_usage": dict(self.llm_token_usage),
"embedding_token_usage": dict(self.embedding_token_usage),
}
if self.total_message_count is not None:
data["total_message_count"] = self.total_message_count
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta":
@@ -125,6 +129,7 @@ class SessionMeta:
participant_user_ids=list(data.get("participant_user_ids", [])),
participant_agent_ids=list(data.get("participant_agent_ids", [])),
message_count=data.get("message_count", 0),
total_message_count=data.get("total_message_count"),
commit_count=data.get("commit_count", 0),
memories_extracted={
"profile": memories.get("profile", 0),
@@ -241,6 +246,7 @@ class Session:
# Old session without meta — derive from existing data
self._meta.message_count = len(self._messages)
self._meta.commit_count = self._compression.compression_index
self._meta.total_message_count = None
if not self._meta.created_by_user_id:
self._meta.created_by_user_id = self.ctx.user.user_id
@@ -284,6 +290,17 @@ class Session:
return
run_async(self._save_meta())
async def _load_latest_meta(self) -> SessionMeta:
"""Load the latest persisted metadata, falling back to the current instance state."""
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
return SessionMeta.from_dict(json.loads(meta_content))
except Exception:
return self._meta
@property
def messages(self) -> List[Message]:
"""Get message list."""
@@ -361,6 +378,8 @@ class Session:
self._append_to_jsonl(msg)
self._meta.message_count = len(self._messages)
if self._meta.total_message_count is not None:
self._meta.total_message_count += 1
self._save_meta_sync()
return msg
@@ -481,8 +500,9 @@ class Session:
ctx=self.ctx,
)
self._meta.message_count = 0
await self._save_meta()
await self._mark_archive_committed_meta(
archive_index=self._compression.compression_index,
)
self._compression.original_count += len(messages_to_archive)
logger.info(
@@ -803,6 +823,9 @@ class Session:
async def get_session_context(self, token_budget: int = 128_000) -> Dict[str, Any]:
"""Get assembled session context with the latest summary archive and merged messages."""
if token_budget < 0:
raise ValueError("token_budget must be greater than or equal to 0")
context = await self._collect_session_context_components()
merged_messages = context["messages"]
@@ -1148,15 +1171,8 @@ class Session:
telemetry_snapshot: Any,
) -> None:
"""Reload and merge latest meta state before persisting commit results."""
latest_meta = self._meta
try:
meta_content = await self._viking_fs.read_file(
f"{self._session_uri}/.meta.json",
ctx=self.ctx,
)
latest_meta = SessionMeta.from_dict(json.loads(meta_content))
except Exception:
latest_meta = self._meta
latest_meta = await self._load_latest_meta()
live_message_count = await self._read_live_message_count()
if telemetry_snapshot:
llm = telemetry_snapshot.summary.get("tokens", {}).get("llm", {})
@@ -1172,8 +1188,21 @@ class Session:
latest_meta.memories_extracted["total"] = (
latest_meta.memories_extracted.get("total", 0) + count
)
latest_meta.message_count = live_message_count
self._meta = latest_meta
await self._save_meta()
async def _mark_archive_committed_meta(
self,
archive_index: int,
) -> None:
"""Persist metadata for a completed Phase 1 archive."""
latest_meta = await self._load_latest_meta()
live_message_count = await self._read_live_message_count()
latest_meta.commit_count = max(latest_meta.commit_count, archive_index)
latest_meta.last_commit_at = get_current_timestamp()
latest_meta.message_count = await self._read_live_message_count()
latest_meta.message_count = live_message_count
self._meta = latest_meta
await self._save_meta()
+1
View File
@@ -138,6 +138,7 @@ class SkillProcessor:
)
return {
"status": "success",
"root_uri": skill_dir_uri,
"uri": skill_dir_uri,
"name": skill_dict["name"],
"auxiliary_files": len(auxiliary_files),
+2
View File
@@ -46,7 +46,9 @@ Use this skill when you need to test skill functionality.
result = await client.add_skill(data=skill_file)
assert "root_uri" in result
assert "uri" in result
assert result["root_uri"] == result["uri"]
assert "viking://agent/skills/" in result["uri"]
async def test_add_skill_from_string(self, client: AsyncOpenViking):
+20 -9
View File
@@ -210,34 +210,45 @@ async def test_find_combines_existing_filter_with_time_range(
}
async def test_find_with_invalid_time_returns_422(client: httpx.AsyncClient):
async def test_find_with_invalid_time_returns_invalid_argument(client: httpx.AsyncClient):
resp = await client.post(
"/api/v1/search/find",
json={"query": "sample", "since": "not-a-time"},
)
assert resp.status_code == 422
assert resp.json()["detail"]
assert resp.status_code == 400
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["message"]
async def test_find_with_invalid_time_field_returns_422(client: httpx.AsyncClient):
async def test_find_with_invalid_time_field_returns_invalid_argument(client: httpx.AsyncClient):
resp = await client.post(
"/api/v1/search/find",
json={"query": "sample", "time_field": "published_at", "since": "2h"},
)
assert resp.status_code == 422
assert resp.json()["detail"]
assert resp.status_code == 400
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["message"]
async def test_find_with_inverted_mixed_time_range_returns_422(client: httpx.AsyncClient):
async def test_find_with_inverted_mixed_time_range_returns_invalid_argument(
client: httpx.AsyncClient,
):
resp = await client.post(
"/api/v1/search/find",
json={"query": "sample", "since": "2099-01-01", "until": "2h"},
)
assert resp.status_code == 422
assert "earlier than or equal to" in resp.json()["detail"]
assert resp.status_code == 400
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert "earlier than or equal to" in body["error"]["message"]
async def test_search_basic(client_with_resource):
+58
View File
@@ -181,6 +181,16 @@ async def test_get_session_context(client: httpx.AsyncClient):
assert [m["parts"][0]["text"] for m in body["result"]["messages"]] == ["Current live message"]
async def test_get_session_context_rejects_negative_token_budget(client: httpx.AsyncClient):
resp = await client.get("/api/v1/sessions/any-session/context?token_budget=-1")
assert resp.status_code == 400
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["details"] == {"field": "token_budget", "value": -1}
async def test_get_session_context_includes_incomplete_archive_messages(
client: httpx.AsyncClient, service
):
@@ -509,6 +519,54 @@ async def test_compress_session(client: httpx.AsyncClient):
assert "telemetry" not in body
async def test_commit_updates_archive_metadata_before_background_task(client: httpx.AsyncClient):
create_resp = await client.post("/api/v1/sessions", json={})
session_id = create_resp.json()["result"]["session_id"]
for content in ["first", "second", "third"]:
resp = await client.post(
f"/api/v1/sessions/{session_id}/messages",
json=_message_request("user", content=content),
)
assert resp.status_code == 200
before_commit = await client.get(f"/api/v1/sessions/{session_id}")
assert before_commit.status_code == 200
before_result = before_commit.json()["result"]
assert before_result["message_count"] == 3
assert before_result["total_message_count"] == 3
assert before_result["commit_count"] == 0
assert before_result["last_commit_at"] == ""
commit_resp = await client.post(f"/api/v1/sessions/{session_id}/commit")
assert commit_resp.status_code == 200
commit_result = commit_resp.json()["result"]
assert commit_result["archived"] is True
immediate_get = await client.get(f"/api/v1/sessions/{session_id}")
assert immediate_get.status_code == 200
immediate_result = immediate_get.json()["result"]
assert immediate_result["message_count"] == 0
assert immediate_result["total_message_count"] == 3
assert immediate_result["commit_count"] == 1
assert immediate_result["last_commit_at"] != ""
await _wait_for_task(client, commit_result["task_id"])
resp = await client.post(
f"/api/v1/sessions/{session_id}/messages",
json=_message_request("user", content="fourth"),
)
assert resp.status_code == 200
after_new_message = await client.get(f"/api/v1/sessions/{session_id}")
assert after_new_message.status_code == 200
after_result = after_new_message.json()["result"]
assert after_result["message_count"] == 1
assert after_result["total_message_count"] == 4
assert after_result["commit_count"] == 1
async def test_extract_session_jsonable_regression(client: httpx.AsyncClient, service, monkeypatch):
"""Regression: extract endpoint should serialize internal objects."""
+30 -8
View File
@@ -6,23 +6,34 @@
import httpx
def _assert_invalid_request_response(resp: httpx.Response):
assert resp.status_code == 400
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["message"]
assert body["error"]["details"]["validation_errors"]
return body
async def test_invalid_json_body(client: httpx.AsyncClient):
"""Sending invalid JSON should return 422."""
"""Sending invalid JSON should return a structured invalid argument error."""
resp = await client.post(
"/api/v1/resources",
content=b"not-valid-json",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 422
_assert_invalid_request_response(resp)
async def test_missing_required_field(client: httpx.AsyncClient):
"""Missing required 'path' field in add_resource should return 422."""
"""Missing required 'path' field should return a structured invalid argument error."""
resp = await client.post(
"/api/v1/resources",
json={"reason": "test"}, # missing 'path'
)
assert resp.status_code == 422
body = _assert_invalid_request_response(resp)
assert "path" in body["error"]["message"]
async def test_not_found_resource_returns_structured_error(
@@ -40,6 +51,17 @@ async def test_not_found_resource_returns_structured_error(
assert "message" in body["error"]
async def test_missing_task_returns_structured_error(client: httpx.AsyncClient):
"""Missing task should use the standard error envelope."""
resp = await client.get("/api/v1/tasks/missing-task-id")
assert resp.status_code == 404
body = resp.json()
assert body["status"] == "error"
assert body["error"]["code"] == "NOT_FOUND"
assert body["error"]["message"] == "Task not found or expired"
async def test_add_resource_file_not_found(client: httpx.AsyncClient):
"""Adding a resource with non-existent file path.
@@ -56,23 +78,23 @@ async def test_add_resource_file_not_found(client: httpx.AsyncClient):
async def test_empty_body_on_post(client: httpx.AsyncClient):
"""POST with empty body should return 422."""
"""POST with empty body should return a structured invalid argument error."""
resp = await client.post(
"/api/v1/resources",
content=b"",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 422
_assert_invalid_request_response(resp)
async def test_wrong_content_type(client: httpx.AsyncClient):
"""POST with wrong content type should return 422."""
"""POST with wrong content type should return a structured invalid argument error."""
resp = await client.post(
"/api/v1/resources",
content=b"path=/tmp/test",
headers={"Content-Type": "text/plain"},
)
assert resp.status_code == 422
_assert_invalid_request_response(resp)
async def test_invalid_uri_format(client: httpx.AsyncClient):
+2
View File
@@ -108,7 +108,9 @@ description: SDK localhost upload test
)
result = await client.add_skill(data=str(f), wait=True)
assert "root_uri" in result
assert "uri" in result
assert result["root_uri"] == result["uri"]
assert result["uri"].startswith("viking://agent/skills/")
@@ -239,6 +239,7 @@ async def test_add_skill_wait_uses_request_tracker_when_telemetry_disabled(servi
timeout=9.0,
)
assert result["root_uri"] == "viking://agent/skills/demo"
assert result["queue_status"] == tracker.queue_status
assert tracker.registered_requests == [telemetry.telemetry_id]
assert tracker.wait_calls == [(telemetry.telemetry_id, 9.0)]