mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-29 04:02:57 +08:00
fix(api): 统一错误 envelope 与会话元数据 (#1764)
* fix(api): standardize error envelopes and session metadata * fix(session): update archive metadata on commit
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)**
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)**
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user