Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions amplifier_app_cli/commands/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def _prepare_resume_context(

# Get project slug for session-scoped settings
project_slug = get_project_slug()
app_settings = app_settings.with_session(session_id, project_slug)

# Resolve configuration using unified function (single source of truth)
config_data, prepared_bundle = resolve_config(
Expand Down Expand Up @@ -1117,8 +1118,10 @@ def sessions_delete(session_id: str, force: bool):
root = SharedRootSession.acquire(session_id)
try:
root.held.check()
if session_path.exists():
shutil.rmtree(session_path)
from amplifier_foundation.session.metadata import metadata_lock
with metadata_lock(session_path):
if session_path.exists():
shutil.rmtree(session_path)
root.delete_checkpoint()
finally:
root.release()
Expand Down
48 changes: 4 additions & 44 deletions amplifier_app_cli/lib/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,10 @@ def with_session(self, session_id: str, project_slug: str) -> "AppSettings":

def get_merged_settings(self) -> dict[str, Any]:
"""Load and merge settings from all scopes."""
result: dict[str, Any] = {}
# Order: global -> project -> local -> session (most specific wins)
paths_to_check = [
self.paths.global_settings,
self.paths.project_settings,
self.paths.local_settings,
]
if self.paths.session_settings:
paths_to_check.append(self.paths.session_settings)
from amplifier_foundation.settings import read_settings

for path in paths_to_check:
if path.exists():
try:
with open(path, encoding="utf-8") as f:
content = yaml.safe_load(f) or {}
result = self._deep_merge(result, content)
except Exception:
pass # Skip malformed files
return result
return read_settings((self.paths.global_settings, self.paths.project_settings,
self.paths.local_settings, self.paths.session_settings))

# ----- UI settings -----

Expand Down Expand Up @@ -382,32 +367,7 @@ def get_provider_overrides(self) -> list[dict[str, Any]]:
More-specific scopes override less-specific: global < project < local < session.
Providers not present in higher scopes pass through from lower scopes.
"""
from .merge_utils import _provider_key, merge_module_items # noqa: F401

result: list[dict[str, Any]] = []

# Scope priority order: global (lowest) → project → local → session (highest)
paths_to_check: list[Path | None] = [
self.paths.global_settings,
self.paths.project_settings,
self.paths.local_settings,
self.paths.session_settings,
]

for path in paths_to_check:
if path is None or not path.exists():
continue
try:
with open(path, encoding="utf-8") as f:
content = yaml.safe_load(f) or {}
scope_providers = content.get("config", {}).get("providers", [])
if not isinstance(scope_providers, list) or not scope_providers:
continue
result = self._merge_provider_lists(result, scope_providers)
except Exception:
pass

return result
return self.get_merged_settings().get("config", {}).get("providers", [])

def _merge_provider_lists(
self,
Expand Down
33 changes: 2 additions & 31 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1849,40 +1849,11 @@ async def _rename_session(self, new_name: str) -> str:
session_id = self.session.coordinator.session_id

try:
from datetime import UTC, datetime

from .session_store import SessionStore
from .shared_root_state import update_root_metadata

store = SessionStore()
updates = {
"name": new_name[:50], # Limit name length
"name_generated_at": datetime.now(UTC).isoformat(),
}
root_state = self.session.coordinator.get_capability("cli.shared_root_state")
if root_state is not None:
# The active root already owns the lock. Preserve complete
# provider context while retaining the shared writer lock.
context = self.session.coordinator.get("context")
if context is None or not hasattr(context, "get_messages"):
return "Cannot rename: root context is unavailable."
messages = await context.get_messages()
metadata = {
**store.get_metadata_if_exists(session_id),
**updates,
"session_id": session_id,
"bundle": self.bundle_name,
}
root_state.checkpoint(
store, messages, bundle=self.bundle_name, metadata=metadata
)
elif self.session.config.get("root_session_id", session_id) != session_id:
# Spawned children never participate in the root writer lock.
store.update_metadata(session_id, updates)
else:
update_root_metadata(store, session_id, updates)

return f"✓ Session renamed to: {new_name[:50]}"
metadata = store.rename(session_id, new_name)
return f"✓ Session renamed to: {metadata['name']}"

except Exception as e:
return f"Failed to rename session: {e}"
Expand Down
4 changes: 3 additions & 1 deletion amplifier_app_cli/session_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,9 @@ async def create_initialized_session(
from amplifier_foundation.configurator import SessionConfigurator

configurator = SessionConfigurator(session, config.prepared_bundle)
app_settings = AppSettings()
from .project_utils import get_project_slug

app_settings = AppSettings().with_session(session_id, get_project_slug())
merged = app_settings.get_merged_settings()
configurator_settings = merged.get("configurator") or {}
await configurator.apply_saved_settings(configurator_settings)
Expand Down
21 changes: 10 additions & 11 deletions amplifier_app_cli/session_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from amplifier_foundation import sanitize_message
from amplifier_foundation import write_with_backup
from amplifier_foundation.session.history import SessionHistoryStore
from amplifier_foundation.session.metadata import SessionMetadataStore

from amplifier_app_cli.project_utils import get_project_slug
from amplifier_foundation.paths.resolution import get_amplifier_home
Expand Down Expand Up @@ -125,7 +126,7 @@ def save(self, session_id: str, transcript: list, metadata: dict) -> None:

# Foundation validates both payloads before writing either native file.
SessionHistoryStore(session_dir).save(
transcript, redact_secrets(metadata), sanitizer=sanitize_message
transcript, redact_secrets(metadata), sanitizer=sanitize_message, merge_metadata=True
)

logger.debug(f"Session {session_id} saved successfully")
Expand All @@ -151,7 +152,7 @@ def save_new(self, session_id: str, transcript: list, metadata: dict) -> None:
session_dir = self.reserve_session(session_id)
try:
SessionHistoryStore(session_dir).save(
transcript, redact_secrets(metadata), sanitizer=sanitize_message
transcript, redact_secrets(metadata), sanitizer=sanitize_message, merge_metadata=True
)
except BaseException:
# We created this directory exclusively, so removing a partial
Expand All @@ -173,7 +174,7 @@ def _save_transcript(self, session_dir: Path, transcript: list) -> None:

def _save_metadata(self, session_dir: Path, metadata: dict) -> None:
"""Save native metadata with the CLI's existing credential redaction."""
SessionHistoryStore(session_dir).save_metadata(redact_secrets(metadata))
SessionHistoryStore(session_dir).save_metadata(redact_secrets(metadata), merge_metadata=True)

def load(self, session_id: str) -> tuple[list, dict]:
"""Load session state with corruption recovery.
Expand Down Expand Up @@ -252,18 +253,16 @@ def update_metadata(self, session_id: str, updates: dict) -> dict:
if not session_dir.exists():
raise FileNotFoundError(f"Session '{session_id}' not found")

# Load current metadata
metadata = self._load_metadata(session_dir)

# Apply updates
metadata.update(updates)

# Save updated metadata
self._save_metadata(session_dir, metadata)
metadata = SessionMetadataStore(session_dir).update(redact_secrets(updates))

logger.debug(f"Session {session_id} metadata updated: {list(updates.keys())}")
return metadata

def rename(self, session_id: str, name: str) -> dict:
"""Rename through Foundation without replacing transcript/runtime state."""
self.get_metadata(session_id) # Keep strict identity/existence validation.
return SessionMetadataStore(self.base_dir / session_id).set_name(name)

def get_metadata(self, session_id: str) -> dict:
"""Get session metadata without loading transcript.

Expand Down
21 changes: 21 additions & 0 deletions docs/SHARED-SESSION-METADATA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Shared session metadata

The CLI and Unified share the native session directory. `metadata.json` is the
authority for names and descriptions; Unified's display cache is not a separate
name store. `/rename` uses Foundation's `SessionMetadataStore.set_name`, with a
shared limit of 200 characters. Delayed automatic names and later runtime saves
preserve an explicit rename from either application.

Foundation serializes metadata updates with a short sibling lock. Session
execution still uses its separate ownership lock. Both applications must be
updated before relying on concurrent metadata edits; an already-running older
process retains its older save behavior until restarted.

Resuming a native session also selects its `settings.yaml` scope. CLI and Unified
use Foundation's shared settings reader with global, project, local, and native
session paths. Provider instances merge by ID or module; unrelated keys and
credential references remain intact. Reading never rewrites configuration.

This preserves session identity, transcript context, and event history. It does
not replay tools or make web-only pending operations, canvas state, or private
runtime-control files executable by the CLI.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dependencies = [
"pydantic>=2.0.0",
"amplifier-core>=1.5.3",
# Tool installs consume published metadata, not this repository's uv.lock.
"amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@2c0063a187181173dfe2438ce031079e8723f894",
"amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main",
"pyyaml>=6.0.3",
"prompt-toolkit>=3.0.52",
"httpx[socks]>=0.28.1",
Expand Down Expand Up @@ -67,7 +67,7 @@ build-backend = "hatchling.build"
dev = ["pytest>=9.0.3", "pytest-asyncio>=0.24.0"]

[tool.uv.sources]
amplifier-foundation = { git = "https://github.com/microsoft/amplifier-foundation", rev = "2c0063a187181173dfe2438ce031079e8723f894" }
amplifier-foundation = { git = "https://github.com/microsoft/amplifier-foundation", rev = "main" }

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
9 changes: 5 additions & 4 deletions tests/test_session_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,8 @@ async def test_configurator_apply_saved_settings_called(self):
saved_settings = {"key": "value"}
merged = {"configurator": saved_settings, "other": "stuff"}
fake_module = self._make_fake_configurator_module(mock_configurator_cls)
settings = MagicMock(get_merged_settings=MagicMock(return_value=merged))
settings.with_session.return_value = settings

with ExitStack() as stack:
for p in _configurator_patches(mock_sess):
Expand All @@ -776,13 +778,12 @@ async def test_configurator_apply_saved_settings_called(self):
stack.enter_context(
patch(
f"{_MODULE}.AppSettings",
return_value=MagicMock(
get_merged_settings=MagicMock(return_value=merged)
),
return_value=settings,
)
)
await create_initialized_session(cfg, console)
initialized = await create_initialized_session(cfg, console)

assert settings.with_session.call_args.args[0] == initialized.session_id
mock_configurator.apply_saved_settings.assert_called_once_with(saved_settings)

@pytest.mark.anyio
Expand Down
5 changes: 4 additions & 1 deletion tests/test_shared_root_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,10 @@ def test_held_read_refreshes_older_cli_native_writes(shared_api, tmp_path):
try:
assert root.read(native)[0][0]["content"] == "first"
# Simulate a transcript-compatible host writing through the shared layer.
native.save("root", [{"role": "user", "content": "second"}], {"name": "renamed"})
from amplifier_foundation.session.history import SessionHistoryStore
SessionHistoryStore(native.base_dir / "root").save(
[{"role": "user", "content": "second"}], {"name": "renamed"}
)
messages, metadata = root.read(native)
assert messages[0]["content"] == "second"
assert metadata["name"] == "renamed"
Expand Down
30 changes: 30 additions & 0 deletions tests/test_shared_session_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""A native session can move between updated hosts without losing its name."""

import pytest

from amplifier_app_cli.session_store import SessionStore
from amplifier_foundation.session.metadata import SessionMetadataStore


def test_external_name_survives_cli_checkpoint_and_cli_rename_is_shared(tmp_path):
store = SessionStore(tmp_path)
messages = [{"role": "user", "content": "Existing conversation"}]
store.save("web-session", messages, {"bundle": "anchors", "other_host": True})
metadata = SessionMetadataStore(tmp_path / "web-session")
metadata.set_name("Created in the web")
stale = store.get_metadata("web-session")
metadata.set_name("Changed in another client")
store.save("web-session", messages, {**stale, "turn_count": 1})
assert store.get_metadata("web-session")["name"] == "Changed in another client"
result = store.rename("web-session", "CLI name " + "x" * 100)
assert metadata.read()["name"] == result["name"]
assert metadata.read()["name_source"] == "manual"
assert metadata.read()["other_host"] is True
assert store.load("web-session")[0] == messages


def test_missing_session_rename_does_not_create_it(tmp_path):
store = SessionStore(tmp_path)
with pytest.raises(FileNotFoundError):
store.rename("missing", "A name")
assert not (tmp_path / "missing").exists()
5 changes: 3 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading