diff --git a/crates/ov_cli/src/commands/session.rs b/crates/ov_cli/src/commands/session.rs index 6746d67653..bcd3ba8839 100644 --- a/crates/ov_cli/src/commands/session.rs +++ b/crates/ov_cli/src/commands/session.rs @@ -364,6 +364,39 @@ pub async fn commit_session( Ok(()) } +/// Update (merge) free-form session metadata. +/// +/// ``key_values`` is a list of ``key=value`` pairs. Pass ``replace=true`` to +/// overwrite the dict entirely instead of merging. +pub async fn set_session_metadata( + client: &HttpClient, + session_id: &str, + key_values: &[(String, String)], + replace: bool, + output_format: OutputFormat, + compact: bool, +) -> Result<()> { + if key_values.is_empty() { + return Err(Error::Client( + "set-metadata requires at least one --key/--value pair".to_string(), + )); + } + let mut metadata = serde_json::Map::new(); + for (key, value) in key_values { + metadata.insert(key.clone(), Value::String(value.clone())); + } + let path = format!("/api/v1/sessions/{}/metadata", url_encode(session_id)); + let body = json!({"metadata": Value::Object(metadata)}); + let params: Vec<(String, String)> = if replace { + vec![("replace".to_string(), "true".to_string())] + } else { + Vec::new() + }; + let response: serde_json::Value = client.patch(&path, &body, ¶ms).await?; + output_success(&response, output_format, compact); + Ok(()) +} + /// Add memory in one shot: creates a session, adds messages, and commits. /// /// Input can be: diff --git a/crates/ov_cli/src/handlers.rs b/crates/ov_cli/src/handlers.rs index ccae601771..bae525165f 100644 --- a/crates/ov_cli/src/handlers.rs +++ b/crates/ov_cli/src/handlers.rs @@ -496,6 +496,31 @@ pub async fn handle_session(cmd: SessionCommands, ctx: CliContext) -> Result<()> commands::session::commit_session(&client, &session_id, ctx.output_format, ctx.compact) .await } + SessionCommands::SetMetadata { + session_id, + keys, + values, + replace, + } => { + if keys.len() != values.len() { + return Err(crate::error::Error::Client(format!( + "set-metadata requires the same number of --key and --value flags (got {} and {})", + keys.len(), + values.len(), + ))); + } + let pairs: Vec<(String, String)> = + keys.into_iter().zip(values.into_iter()).collect(); + commands::session::set_session_metadata( + &client, + &session_id, + &pairs, + replace, + ctx.output_format, + ctx.compact, + ) + .await + } } } diff --git a/crates/ov_cli/src/main.rs b/crates/ov_cli/src/main.rs index 38fb5386f6..f1222349bb 100644 --- a/crates/ov_cli/src/main.rs +++ b/crates/ov_cli/src/main.rs @@ -1130,6 +1130,21 @@ enum SessionCommands { #[arg(value_name = "session-id")] session_id: String, }, + /// Merge free-form session metadata (or replace it) + SetMetadata { + /// Session ID + #[arg(value_name = "session-id")] + session_id: String, + /// Metadata key (repeatable, paired positionally with --value) + #[arg(long = "key", value_name = "key", num_args = 1..)] + keys: Vec, + /// Metadata value (repeatable, paired positionally with --key) + #[arg(long = "value", value_name = "value", num_args = 1..)] + values: Vec, + /// Replace existing metadata entirely instead of merging + #[arg(long)] + replace: bool, + }, } #[derive(Subcommand)] diff --git a/openviking/server/routers/sessions.py b/openviking/server/routers/sessions.py index bc173b98a2..41a925bed8 100644 --- a/openviking/server/routers/sessions.py +++ b/openviking/server/routers/sessions.py @@ -123,6 +123,14 @@ class CreateSessionRequest(BaseModel): session_id: Optional[str] = None memory_policy: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None + telemetry: TelemetryRequest = False + + +class UpdateMetadataRequest(BaseModel): + """Request model for updating session metadata.""" + + metadata: Dict[str, Any] = Field(default_factory=dict) telemetry: TelemetryRequest = False @@ -188,7 +196,12 @@ async def create_session( If session_id is provided, creates a session with the given ID. If session_id is None, creates a new session with auto-generated ID. + Optional ``metadata`` carries free-form per-session personalization + (project name, tech-stack preferences, etc.) that is later injected into + the memory extractor's prompt. """ + from openviking.session.session_metadata import MetadataValidationError + service = get_service() async def _create() -> dict[str, Any]: @@ -197,18 +210,23 @@ async def _create() -> dict[str, Any]: _ctx, request.session_id, memory_policy=request.memory_policy, + metadata=request.metadata, ) return { "session_id": session.session_id, "uri": session.uri, "user": session.user.to_dict(), + "metadata": session.meta.metadata, } - execution = await run_operation( - operation="session.create", - telemetry=request.telemetry, - fn=_create, - ) + try: + execution = await run_operation( + operation="session.create", + telemetry=request.telemetry, + fn=_create, + ) + except MetadataValidationError as exc: + return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"}) return Response(status="ok", result=execution.result, telemetry=execution.telemetry) @@ -357,6 +375,39 @@ async def delete_session( return Response(status="ok", result={"session_id": session_id}) +@router.patch("/{session_id}/metadata") +async def update_session_metadata( + request: UpdateMetadataRequest, + session_id: str = Path(..., description="Session ID"), + replace: bool = Query( + False, + description="If true, replace existing metadata entirely instead of merging.", + ), + _ctx: RequestContext = Depends(get_request_context), +): + """Merge (or replace) session metadata. + + By default, keys in the request body are merged into the existing + metadata; pass ``replace=true`` to overwrite the dict entirely. + """ + from openviking.session.session_metadata import MetadataValidationError + from openviking_cli.exceptions import NotFoundError + + service = get_service() + try: + metadata = await service.sessions.update_metadata( + session_id, + _ctx, + request.metadata, + replace=replace, + ) + except MetadataValidationError as exc: + return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"}) + except NotFoundError: + return error_response("NOT_FOUND", f"Session {session_id} not found") + return Response(status="ok", result={"session_id": session_id, "metadata": metadata}) + + class CommitRequest(BaseModel): """Commit request body. diff --git a/openviking/service/session_service.py b/openviking/service/session_service.py index 806af8ffdd..2baf611c5f 100644 --- a/openviking/service/session_service.py +++ b/openviking/service/session_service.py @@ -15,6 +15,11 @@ from openviking.session import Session from openviking.session.memory.memory_type_registry import MemoryTypeRegistry from openviking.session.memory_policy import MemoryPolicy +from openviking.session.session_metadata import ( + MetadataValidationError, + merge_metadata, + validate_metadata, +) from openviking.storage import VikingDBManager from openviking.storage.viking_fs import VikingFS from openviking_cli.exceptions import ( @@ -127,6 +132,7 @@ async def create( ctx: RequestContext, session_id: Optional[str] = None, memory_policy: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> Session: """Create a session and persist its root path. @@ -135,6 +141,8 @@ async def create( session_id: Optional session ID. If provided, creates a session with the given ID. If None, creates a new session with auto-generated ID. memory_policy: Optional default extraction policy for future commits. + metadata: Optional free-form per-session metadata dict (project name, + tech-stack preferences, etc.). Validated for size and key count. Raises: AlreadyExistsError: If a session with the given ID already exists @@ -152,6 +160,8 @@ async def create( set(MemoryTypeRegistry().list_names(include_disabled=False)) ) session.meta.memory_policy = policy.to_dict() + if metadata is not None: + session.meta.metadata = validate_metadata(metadata) await session.ensure_exists() self._record_lifecycle_metric("create", "ok") return session @@ -159,6 +169,26 @@ async def create( self._record_lifecycle_metric("create", "error") raise + async def update_metadata( + self, + session_id: str, + ctx: RequestContext, + metadata: Dict[str, Any], + *, + replace: bool = False, + ) -> Optional[Dict[str, Any]]: + """Merge (or replace) session metadata and persist it. + + Returns the resulting metadata dict. + """ + if not isinstance(metadata, dict): + raise MetadataValidationError("metadata must be a JSON object") + session = await self.get(session_id, ctx, auto_create=False) + merged = merge_metadata(session.meta.metadata, metadata, replace=replace) + session.meta.metadata = validate_metadata(merged) + await session._save_meta() # noqa: SLF001 — service intentionally persists meta + return session.meta.metadata + async def get( self, session_id: str, ctx: RequestContext, *, auto_create: bool = False ) -> Session: @@ -325,6 +355,7 @@ async def extract(self, session_id: str, ctx: RequestContext) -> List[Any]: session_id=session_id, ctx=ctx, archive_uri=archive_uri, + session_metadata=session.meta.metadata, ) self._record_lifecycle_metric("extract", "ok") return memories diff --git a/openviking/session/compressor_v2.py b/openviking/session/compressor_v2.py index 9ba691b22b..2ffa35b0a2 100644 --- a/openviking/session/compressor_v2.py +++ b/openviking/session/compressor_v2.py @@ -232,6 +232,7 @@ async def extract_long_term_memories( allowed_memory_types: Optional[set[str]] = None, allow_self_memory: bool = True, allowed_peer_ids: Optional[set[str]] = None, + session_metadata: Optional[Dict[str, Any]] = None, ) -> List[Context]: """Extract long-term memories from messages using v2 templating system. @@ -308,6 +309,7 @@ async def extract_long_term_memories( ctx=ctx, viking_fs=viking_fs, transaction_handle=transaction_handle, + session_metadata=session_metadata, ) await context_provider.prepare_extraction_messages() extract_context = context_provider.get_extract_context() diff --git a/openviking/session/memory/session_extract_context_provider.py b/openviking/session/memory/session_extract_context_provider.py index 5dbdcb18d0..d53ffa130b 100644 --- a/openviking/session/memory/session_extract_context_provider.py +++ b/openviking/session/memory/session_extract_context_provider.py @@ -65,6 +65,7 @@ def __init__( ctx: RequestContext = None, viking_fs: VikingFS = None, transaction_handle=None, + session_metadata: Optional[Dict[str, Any]] = None, ): self.messages = list(messages) if isinstance(messages, list) else messages self.latest_archive_overview = latest_archive_overview @@ -84,6 +85,7 @@ def __init__( self._link_enabled = config.memory.link_enabled if config.memory else False self._vision_messages_prepared = False self._vision_vlm = None + self._session_metadata = session_metadata @property def read_file_contents(self) -> Dict[str, MemoryFile]: @@ -185,6 +187,8 @@ def _conversation_contains_resource_uri(self) -> bool: return False def instruction(self) -> str: + from openviking.session.session_metadata import render_metadata_prompt_block + output_language = self._output_language resource_uri_handling = ( """ @@ -200,7 +204,9 @@ def instruction(self) -> str: if self._conversation_contains_resource_uri() else "" ) - goal = f"""You are a memory extraction agent. Your task is to analyze conversations and update memories. + metadata_block = render_metadata_prompt_block(self._session_metadata) + metadata_section = f"{metadata_block}\n\n" if metadata_block else "" + goal = f"""{metadata_section}You are a memory extraction agent. Your task is to analyze conversations and update memories. ## Workflow 1. Analyze the conversation and pre-fetched context diff --git a/openviking/session/session.py b/openviking/session/session.py index c36c2b4833..a1ccf7b800 100644 --- a/openviking/session/session.py +++ b/openviking/session/session.py @@ -267,6 +267,11 @@ class SessionMeta: # process restarts. keep_recent_count: int = 0 memory_policy: Optional[Dict[str, Any]] = None + # Free-form, project-level personalization (architectural style, tech-stack + # preferences, project name, etc.). Injected into the memory extractor's + # system prompt so a single agent can keep distinct memory layers across + # projects without having to allocate a different agent_id per project. + metadata: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: data = { @@ -284,6 +289,7 @@ def to_dict(self) -> Dict[str, Any]: "pending_tokens": self.pending_tokens, "keep_recent_count": self.keep_recent_count, "memory_policy": dict(self.memory_policy) if self.memory_policy is not None else None, + "metadata": dict(self.metadata) if self.metadata is not None else None, } if self.total_message_count is not None: data["total_message_count"] = self.total_message_count @@ -327,6 +333,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta": pending_tokens=max(0, int(data.get("pending_tokens", 0) or 0)), keep_recent_count=max(0, int(data.get("keep_recent_count", 0) or 0)), memory_policy=data.get("memory_policy"), + metadata=data.get("metadata"), ) @@ -1425,6 +1432,7 @@ async def _run_archive_summary() -> None: allowed_memory_types=long_term_memory_types, allow_self_memory=self_memory_enabled, allowed_peer_ids=allowed_peer_ids, + session_metadata=self._meta.metadata, ) ) extraction_labels.append("long_term") diff --git a/openviking/session/session_metadata.py b/openviking/session/session_metadata.py new file mode 100644 index 0000000000..130be61152 --- /dev/null +++ b/openviking/session/session_metadata.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Session metadata helpers: validation, merging, prompt rendering. + +Sessions can carry a free-form ``metadata: dict[str, Any]`` field used to +express project-level personalization (architectural style, tech-stack +preferences, project name, etc.). Limits are intentionally hard-coded as +module constants — there is no config field for them. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +# Maximum serialized JSON size for ``Session.metadata`` in bytes. +METADATA_MAX_BYTES: int = 16 * 1024 +# Maximum number of top-level keys. +METADATA_MAX_KEYS: int = 64 + + +class MetadataValidationError(ValueError): + """Raised when session metadata fails validation.""" + + +def validate_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Validate a metadata dict against size, key-count and JSON-serializability. + + Returns the original dict (unchanged) when valid. Raises + :class:`MetadataValidationError` otherwise. ``None`` is allowed and passes + through. + """ + if metadata is None: + return None + if not isinstance(metadata, dict): + raise MetadataValidationError("metadata must be a JSON object") + if len(metadata) > METADATA_MAX_KEYS: + raise MetadataValidationError( + f"metadata has {len(metadata)} keys (max {METADATA_MAX_KEYS})" + ) + try: + encoded = json.dumps(metadata, ensure_ascii=False).encode("utf-8") + except (TypeError, ValueError) as exc: + raise MetadataValidationError(f"metadata is not JSON-serializable: {exc}") from exc + if len(encoded) > METADATA_MAX_BYTES: + raise MetadataValidationError( + f"metadata is {len(encoded)} bytes (max {METADATA_MAX_BYTES})" + ) + return metadata + + +def merge_metadata( + existing: Optional[Dict[str, Any]], + incoming: Dict[str, Any], + *, + replace: bool = False, +) -> Dict[str, Any]: + """Merge ``incoming`` into ``existing`` (or replace entirely). + + With ``replace=False`` (default) keys from ``incoming`` overwrite matching + keys in ``existing`` while preserving the rest. With ``replace=True`` the + result is exactly ``incoming``. + """ + if replace: + return dict(incoming) + merged: Dict[str, Any] = dict(existing or {}) + merged.update(incoming) + return merged + + +def render_metadata_prompt_block(metadata: Optional[Dict[str, Any]]) -> str: + """Render a delimited ``[Session metadata]`` block for the LLM system prompt. + + Returns an empty string when ``metadata`` is ``None`` or empty so callers + can unconditionally concatenate the result. + """ + if not metadata: + return "" + lines = ["[Session metadata]"] + for key, value in metadata.items(): + if isinstance(value, (dict, list)): + rendered = json.dumps(value, ensure_ascii=False) + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + lines.append("[/Session metadata]") + return "\n".join(lines) diff --git a/tests/server/test_session_metadata.py b/tests/server/test_session_metadata.py new file mode 100644 index 0000000000..40bdd16437 --- /dev/null +++ b/tests/server/test_session_metadata.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: AGPL-3.0 +"""Tests for session metadata: persistence, API, and extractor prompt injection.""" + +import json +from unittest.mock import patch + +import httpx +import pytest + +from openviking.session.session_metadata import ( + METADATA_MAX_BYTES, + METADATA_MAX_KEYS, + MetadataValidationError, + merge_metadata, + render_metadata_prompt_block, + validate_metadata, +) +from openviking_cli.utils.config import OPENVIKING_CONFIG_ENV +from openviking_cli.utils.config.open_viking_config import OpenVikingConfigSingleton +from tests.utils.mock_agfs import MockLocalAGFS + + +@pytest.fixture(autouse=True) +def _configure_test_env(monkeypatch, tmp_path): + """Per-file env setup mirroring tests/server/test_api_sessions.py.""" + config_path = tmp_path / "ov.conf" + config_path.write_text( + json.dumps( + { + "storage": { + "workspace": str(tmp_path / "workspace"), + "agfs": {"backend": "local"}, + "vectordb": {"backend": "local"}, + }, + "embedding": { + "dense": { + "provider": "openai", + "model": "test-embedder", + "api_base": "http://127.0.0.1:11434/v1", + "dimension": 1024, + } + }, + "encryption": {"enabled": False}, + } + ), + encoding="utf-8", + ) + + mock_agfs = MockLocalAGFS(root_path=tmp_path / "mock_agfs_root") + + monkeypatch.setenv(OPENVIKING_CONFIG_ENV, str(config_path)) + OpenVikingConfigSingleton.reset_instance() + + with patch("openviking.utils.agfs_utils.create_agfs_client", return_value=mock_agfs): + yield + + OpenVikingConfigSingleton.reset_instance() + + +# --------------------------------------------------------------------------- +# Pure helper unit tests +# --------------------------------------------------------------------------- + + +def test_validate_metadata_accepts_none(): + assert validate_metadata(None) is None + + +def test_validate_metadata_accepts_simple_dict(): + payload = {"project": "alpha", "stack": ["go", "rust"]} + assert validate_metadata(payload) == payload + + +def test_validate_metadata_rejects_oversized_payload(): + big_value = "x" * (METADATA_MAX_BYTES + 1) + with pytest.raises(MetadataValidationError): + validate_metadata({"blob": big_value}) + + +def test_validate_metadata_rejects_too_many_keys(): + payload = {f"k{i}": i for i in range(METADATA_MAX_KEYS + 1)} + with pytest.raises(MetadataValidationError): + validate_metadata(payload) + + +def test_merge_metadata_merges_by_default(): + existing = {"project": "alpha", "lang": "go"} + incoming = {"lang": "rust", "owner": "yeyitech"} + assert merge_metadata(existing, incoming) == { + "project": "alpha", + "lang": "rust", + "owner": "yeyitech", + } + + +def test_merge_metadata_replace_overwrites(): + existing = {"project": "alpha", "lang": "go"} + incoming = {"owner": "yeyitech"} + assert merge_metadata(existing, incoming, replace=True) == {"owner": "yeyitech"} + + +def test_render_metadata_prompt_block_empty_returns_empty_string(): + assert render_metadata_prompt_block(None) == "" + assert render_metadata_prompt_block({}) == "" + + +def test_render_metadata_prompt_block_includes_delimiters_and_keys(): + block = render_metadata_prompt_block({"project": "alpha", "stack": ["go", "rust"]}) + assert block.startswith("[Session metadata]") + assert block.endswith("[/Session metadata]") + assert "project: alpha" in block + assert '"go"' in block # list values rendered as JSON + + +# --------------------------------------------------------------------------- +# HTTP API tests +# --------------------------------------------------------------------------- + + +async def test_create_session_with_metadata(client: httpx.AsyncClient): + metadata = {"project": "alpha", "stack": "go"} + resp = await client.post("/api/v1/sessions", json={"metadata": metadata}) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["result"]["metadata"] == metadata + session_id = body["result"]["session_id"] + + get_resp = await client.get(f"/api/v1/sessions/{session_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["result"]["metadata"] == metadata + + +async def test_update_metadata_merge(client: httpx.AsyncClient): + create_resp = await client.post( + "/api/v1/sessions", json={"metadata": {"project": "alpha", "lang": "go"}} + ) + session_id = create_resp.json()["result"]["session_id"] + + patch_resp = await client.patch( + f"/api/v1/sessions/{session_id}/metadata", + json={"metadata": {"lang": "rust", "owner": "yeyitech"}}, + ) + assert patch_resp.status_code == 200 + assert patch_resp.json()["result"]["metadata"] == { + "project": "alpha", + "lang": "rust", + "owner": "yeyitech", + } + + get_resp = await client.get(f"/api/v1/sessions/{session_id}") + assert get_resp.json()["result"]["metadata"] == { + "project": "alpha", + "lang": "rust", + "owner": "yeyitech", + } + + +async def test_update_metadata_replace(client: httpx.AsyncClient): + create_resp = await client.post( + "/api/v1/sessions", json={"metadata": {"project": "alpha", "lang": "go"}} + ) + session_id = create_resp.json()["result"]["session_id"] + + patch_resp = await client.patch( + f"/api/v1/sessions/{session_id}/metadata?replace=true", + json={"metadata": {"owner": "yeyitech"}}, + ) + assert patch_resp.status_code == 200 + assert patch_resp.json()["result"]["metadata"] == {"owner": "yeyitech"} + + +async def test_metadata_size_limit(client: httpx.AsyncClient): + # ~17 KB blob + payload = {"blob": "x" * (METADATA_MAX_BYTES + 1024)} + resp = await client.post("/api/v1/sessions", json={"metadata": payload}) + assert resp.status_code == 400 + body = resp.json() + assert body["status"] == "error" + assert body["error"]["code"] == "INVALID_ARGUMENT" + + +async def test_metadata_keycount_limit(client: httpx.AsyncClient): + payload = {f"k{i}": i for i in range(METADATA_MAX_KEYS + 1)} + resp = await client.post("/api/v1/sessions", json={"metadata": payload}) + assert resp.status_code == 400 + body = resp.json() + assert body["status"] == "error" + assert body["error"]["code"] == "INVALID_ARGUMENT" + + +async def test_metadata_persists_through_load(client: httpx.AsyncClient, service): + """The metadata field must round-trip through the on-disk .meta.json store.""" + metadata = {"project": "alpha", "stack": ["go", "rust"]} + create_resp = await client.post("/api/v1/sessions", json={"metadata": metadata}) + session_id = create_resp.json()["result"]["session_id"] + + # Reload session via service to bypass any in-memory cache (each call creates + # a fresh Session object, so .load() reads the persisted .meta.json). + from openviking.server.identity import RequestContext, Role + from openviking_cli.session.user_id import UserIdentifier + + ctx = RequestContext(user=UserIdentifier.the_default_user(), role=Role.ROOT) + fresh = service.sessions.session(ctx, session_id) + await fresh.load() + assert fresh.meta.metadata == metadata + + +# --------------------------------------------------------------------------- +# Extractor prompt-injection tests +# --------------------------------------------------------------------------- + + +def test_extractor_includes_metadata_in_prompt(): + """The extractor's system instruction must include a [Session metadata] block.""" + from openviking.session.memory.session_extract_context_provider import ( + SessionExtractContextProvider, + ) + + metadata = {"project": "alpha", "tech_stack": "go,rust"} + provider = SessionExtractContextProvider( + messages=[], + session_metadata=metadata, + ) + instruction = provider.instruction() + assert "[Session metadata]" in instruction + assert "[/Session metadata]" in instruction + assert "project: alpha" in instruction + assert "tech_stack: go,rust" in instruction + + +def test_extractor_no_metadata_block_when_empty(): + from openviking.session.memory.session_extract_context_provider import ( + SessionExtractContextProvider, + ) + + provider_none = SessionExtractContextProvider(messages=[], session_metadata=None) + assert "[Session metadata]" not in provider_none.instruction() + + provider_empty = SessionExtractContextProvider(messages=[], session_metadata={}) + assert "[Session metadata]" not in provider_empty.instruction() + + +def test_extractor_prompt_block_threaded_through_session_meta(): + """SessionMeta.metadata round-trips through to_dict/from_dict.""" + # SessionMeta should default metadata to None and accept a dict. + from openviking.session.session import SessionMeta + + meta = SessionMeta() + assert meta.metadata is None + meta.metadata = {"project": "alpha"} + + serialized = json.dumps(meta.to_dict()) + parsed = SessionMeta.from_dict(json.loads(serialized)) + assert parsed.metadata == {"project": "alpha"}