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
2 changes: 1 addition & 1 deletion amplifier_web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Amplifier's local web host."""
__version__ = "0.76.0"
__version__ = "0.76.1"
3 changes: 2 additions & 1 deletion amplifier_web/host/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,10 @@ def installed_package_source(distribution_name, package_name):


class HostComponents:
def __init__(self, sources=None, installed=None):
def __init__(self, sources=None, installed=None, *, resolved_plan=False):
self.sources = dict(sources or {})
self.installed = dict(installed or {})
self.resolved_plan = resolved_plan
self.repositories = {}
self.roots = {}
self.path_roots = {}
Expand Down
10 changes: 7 additions & 3 deletions amplifier_web/host/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,18 +835,18 @@ def installed_naming_source():
return installed_package_source('amplifier-module-hooks-session-naming', 'amplifier_module_hooks_session_naming')


def required_components():
def required_components(*, resolved_plan=False):
# Any bundle naming hook and the app adapter must share one implementation.
# Resolve the included dependency without changing saved mount declarations.
return HostComponents({'loop-live': LOOP_SOURCE}, {'loop-live': installed_loop_source,
'hooks-session-naming': installed_naming_source})
'hooks-session-naming': installed_naming_source}, resolved_plan=resolved_plan)


def module_source(config, snapshot, module, source, components=None):
components = components or required_components()
selected = components.source(module, source, installed=True)
owned = components.owns(module, source)
result = selected if owned else source if snapshot else config.module_sources.get(module) or source
result = selected if owned else source if snapshot or components.resolved_plan else config.module_sources.get(module) or source
observer = getattr(config, '_source_policy_observer', None)
if observer:
# Observe the winning host declaration too. A resolved relative module
Expand Down Expand Up @@ -947,6 +947,10 @@ def apply_runtime_plan(loaded, edited, config, execution_workspace):
for key in ("session", "agents", "context", "instruction"):
if key in edited:
setattr(loaded, key, edited[key])
# The saved plan already includes bundle/settings source selection. Reusing
# that composition's maps would overwrite later per-conversation edits and
# distinct child/instance sources. Only required host components still win.
loaded._host_components = required_components(resolved_plan=True)
from ..write_policy_refresh import WritePolicySource
# An edited live plan is already composed. Retain its explicit ceilings,
# rather than trusting provenance imported from bundle data.
Expand Down
1 change: 1 addition & 0 deletions amplifier_web/release-notes.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"schemaVersion": 1,
"releases": [
{"version":"0.76.1","title":"Honor saved component sources when conversations restart","changes":["Conversation startup now uses the component sources saved for that conversation, including local repairs and distinct child or named-instance sources. Earlier bundle defaults no longer silently replace these choices.","Required host components keep their supported installed sources, and credential binding and loaded-configuration checks remain in force. Saved conversation history and unresolved recovery records are preserved."],"notices":[]},
{"version":"0.76.0","title":"Safer recovery and saved conversation choices","changes":["Let authenticated Full-access root operators save another own chat's Next message choices and registered workspace defaults through app controls. Bounded readback distinguishes saved choices from active configuration; existing holds, Stop and runtime-control boundaries remain in force.","Retire obsolete mount-freshness markers only after proving that their worker is absent, without clearing the original configuration operation or its recovery fence. Held configuration mismatches now report fixed, value-free section and difference categories.","Keep permitted file-access declarations stable through host-policy intersection, preventing a demonstrated save-and-remount ordering mismatch while preserving the same allowed paths, denials and strict loaded-plan verification. This does not establish the cause or resolution of earlier held conversations.","Recognize independently verified selected-source identities for redundant same-repository sibling mappings in immutable shared storage. Preserve genuine pins and source-policy refusals rather than relying on cache labels or deleting cached sources.","Restore a verified, unchanged prior application generation when a newly promoted update fails its probe. Uncertain recovery retains the safety fence; native macOS cold-start and launchd acceptance remain unqualified.","Resolve native delegate provider identities consistently, including bare module names and exact configured account IDs, using the accepted Foundation repair. Existing conversation runtime environments are not replaced by an app release alone.","Align retained Memory module dependencies with Unified's existing selected Memory source without advancing their module pins, and clarify canonical-client authoring and whole-copy delivery ownership."],"notices":[{"id":"full-saved-peer-choices","title":"Full-access operators can save other own chats' future choices","detail":"Authenticated Full-access root operators can now save another own chat's Next message settings and registered workspace defaults. This is a saved-choice operation, not permission to Send, stop or restart another chat, replace an active mount, or clear a held configuration operation.","action":"Review saved Next message choices and workspace defaults before the next turn. Keep Full access limited to operators you trust with these settings; active work and unresolved recovery holds retain their separate controls."}]},
{"version":"0.75.7","title":"Keep conversation workers recoverable when startup is cancelled","changes":["Cancelled worker startup or wake-up now finishes releasing its conversation lock before stopping, so a later worker can continue without waiting for the old process to exit. Saved conversation history and Stop behavior are preserved.","Worker preparation and component updates verify the required shared-session support before opening history. An older retained component generation receives update guidance instead of starting with incompatible ownership handling."],"notices":[]},
{"version":"0.75.6","title":"Keep saved conversation writes consistent across clients","changes":["Use the shared conversation write guard to detect conflicting transcript changes during runtime recovery and checkpoints, preserving saved history.","Check that included components support this protection before starting a conversation or accepting an updated runtime. Incompatible pinned components retain their current state and show update guidance."],"notices":[]},
Expand Down
5 changes: 5 additions & 0 deletions docs/workspaces/configuration-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,11 @@ A terminal sealed native save receipt and an unchanged saved plan allow explicit
resume to finish that same operation's loaded readback. It observes the exact
existing worker first; only an absent worker may mount the proven saved plan.
This continuation never repeats the settings write or submits conversation input.
A saved conversation plan retains its selected module sources through dependency
preparation and worker mounting, including distinct sources for child or named
module instances. Earlier bundle composition and installation defaults cannot
replace those choices. Required host components retain their host-owned sources;
credential binding and loaded-plan verification still apply to the resulting plan.
A different loaded plan remains held. Readback may equate relocated module source
declarations through qualified generations and matching recorded repository,
revision, subdirectory and source-content digest. If an ecosystem update has
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "amplifier-unified"
version = "0.76.0"
version = "0.76.1"
description = "A local voice-first Amplifier workspace with a shared user and agent interface"
readme = "README.md"
requires-python = ">=3.13"
Expand Down
56 changes: 56 additions & 0 deletions tests/test_host_session_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,62 @@ async def test_execution_write_policy_follows_checkout_on_all_prepare_routes(mou
h.session.execute.assert_not_called()


@pytest.mark.parametrize('route', ['dependencies', 'manager', 'saved'])
@pytest.mark.parametrize('local_source', [False, True])
async def test_saved_module_sources_survive_preparation_and_mount(mounted_host, tmp_path, monkeypatch, route, local_source):
from amplifier_web.host.components import HostComponents, declarations
from amplifier_web.runtime_controls import override_path
h = mounted_host
old = 'git+https://github.com/example/tools@main#subdirectory=tool'
chosen = str(tmp_path / 'repair') if local_source else old.replace('@main', '@reviewed')
child = old.replace('@main', '@child-pin')
h.config.providers = []
h.config.module_sources = {'tool-fixture': old.replace('@main', '@global-default')}
h.loaded.providers = [{'module': 'provider-fixture'}]
h.loaded.tools = [{'module': 'tool-fixture', 'source': old}]
h.loaded.hooks = [{'module': 'hooks-session-naming', 'source': old}]
h.loaded.agents = {}
h.loaded.session = {'orchestrator': {'module': 'loop-live'}, 'context': {'module': 'context-simple'}}
h.loaded.to_mount_plan = lambda: copy.deepcopy({key: getattr(h.loaded, key)
for key in ('providers', 'tools', 'hooks', 'agents', 'session')})
# Composition has already chosen defaults. The saved conversation then
# chooses a different source, including a distinct instance in its child.
h.loaded._host_components = HostComponents({'tool-fixture': old})
edited = h.loaded.to_mount_plan()
edited['tools'][0]['source'] = chosen
edited['agents'] = {'child': {'tools': [{'module': 'tool-fixture', 'source': child}]}}
monkeypatch.setattr(host, 'installed_loop_source', lambda: '/installed/loop-live')
monkeypatch.setattr(host, 'installed_naming_source', lambda: '/installed/session-naming')
captured = {}
async def prepare_bundle(**kwargs):
captured['plan'] = h.loaded.to_mount_plan()
captured['sources'] = [(row['module'], kwargs['source_resolver'](row['module'], row.get('source')))
for row in declarations(captured['plan'])]
h.prepared.mount_plan = copy.deepcopy(captured['plan'])
return h.prepared
h.loaded.prepare.side_effect = prepare_bundle
messages = [{'role': 'user', 'content': 'Do not replay completed work'}]
h.store.save('native-root', messages, {'bundle': 'anchors'})
original = h.path.read_bytes()
if route == 'dependencies':
await host.prepare_dependencies(h.config.workspace, runtime_plan=edited)
h.prepared.create_session.assert_not_awaited()
elif route == 'manager':
await h.prepare(runtime_plan=edited)
else:
path = override_path(h.runtime.session_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(edited))
await h.prepare()
assert captured['plan']['tools'][0]['source'] == chosen
assert captured['plan']['agents']['child']['tools'][0]['source'] == child
assert [value for module, value in captured['sources'] if module == 'tool-fixture'] == [chosen, child]
assert ('loop-live', '/installed/loop-live') in captured['sources']
assert ('hooks-session-naming', '/installed/session-naming') in captured['sources']
assert h.path.read_bytes() == original
h.session.execute.assert_not_called()


def nested_checkpoint_scope(h):
from amplifier_web.coordination_delivery import WORK_NESTED
coordinator = h.session.coordinator
Expand Down
7 changes: 4 additions & 3 deletions tests/test_runtime_policy_reconciliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,12 @@ async def test_inactive_catalog_entries_are_never_observed(selected):


async def test_host_observes_effective_module_override(selected):
from amplifier_web.host.components import HostComponents
from amplifier_web.host.session import module_source
s = selected
s.config._source_policy_observer = migration.Collector()
s.config.settings['sources'] = {'modules': {'fixture': 'git+' + URL + '@' + s.old}}
components = SimpleNamespace(source=lambda m, source, **kw: source, owns=lambda m, source: False)
components = HostComponents()
assert module_source(s.config, False, 'fixture', 'git+' + URL + '@main', components).endswith(s.old)
assert s.config._source_policy_observer.references == {(URL, s.old)}

Expand Down Expand Up @@ -579,6 +580,7 @@ def test_retained_extra_usage_requires_matching_completed_profile_selection(sele
@pytest.mark.parametrize('form', ['relative', 'parent-relative', 'file-subdirectory'])
async def test_real_resolver_local_module_override_never_becomes_branch(selected, monkeypatch, form):
from amplifier_foundation.sources.resolver import SimpleSourceResolver
from amplifier_web.host.components import HostComponents
from amplifier_web.host.session import module_source
s = selected
monkeypatch.chdir(s.config.workspace)
Expand All @@ -590,8 +592,7 @@ async def test_real_resolver_local_module_override_never_becomes_branch(selected
reference = s.root.parent.as_uri() + '#subdirectory=' + s.root.name
s.config._source_policy_observer = s.collector
s.config.settings['sources'] = {'modules': {'fixture': reference}}
components = SimpleNamespace(source=lambda module, source, **kw: source,
owns=lambda module, source: False)
components = HostComponents()
effective = module_source(s.config, False, 'fixture', 'git+' + URL + '@main', components)
resolved = await SimpleSourceResolver(cache_dir=s.config.registry_home / 'cache',
base_path=s.config.workspace).resolve(effective)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

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

Loading