From 2818e0519f1c59c71d6a5b53eee50592853f8d7e Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:19:09 -0700 Subject: [PATCH 1/9] feat: add reusable session ID prefix resolver Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/__init__.py | 8 ++++ context_intelligence/session_ids.py | 60 +++++++++++++++++++++++++++++ tests/test_session_ids.py | 57 +++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 context_intelligence/session_ids.py create mode 100644 tests/test_session_ids.py diff --git a/context_intelligence/__init__.py b/context_intelligence/__init__.py index a5008b8c..d21260be 100644 --- a/context_intelligence/__init__.py +++ b/context_intelligence/__init__.py @@ -44,6 +44,11 @@ sessions_dir_for_project, workspace_slug, ) +from context_intelligence.session_ids import ( + SessionResolutionError, + is_safe_session_id, + resolve_session_id, +) __all__ = [ "AsyncCIClient", @@ -68,4 +73,7 @@ "discover_sessions", "workspace_slug", "sessions_dir_for_project", + "SessionResolutionError", + "is_safe_session_id", + "resolve_session_id", ] diff --git a/context_intelligence/session_ids.py b/context_intelligence/session_ids.py new file mode 100644 index 00000000..7b3c5027 --- /dev/null +++ b/context_intelligence/session_ids.py @@ -0,0 +1,60 @@ +"""Resolve session references without knowing a host's storage layout. + +Hosts supply IDs from their authorized scope. Lookup never reads transcripts, +queries a server, guesses a match, or expands that scope. +""" + +from __future__ import annotations + +from collections.abc import Iterable + + +class SessionResolutionError(ValueError): + """A reference is invalid, absent, or ambiguous within the supplied scope.""" + + def __init__(self, code: str, message: str, candidates: tuple[str, ...] = ()) -> None: + super().__init__(message) + self.code = code + self.candidates = candidates + + +def is_safe_session_id(value: object) -> bool: + """Accept opaque IDs, never paths, whitespace, or glob expressions.""" + return ( + isinstance(value, str) + and bool(value) + and value not in {".", ".."} + and not any(character.isspace() for character in value) + and not any(character in value for character in "/\\\0*?[]") + ) + + +def resolve_session_id(reference: str, candidates: Iterable[str]) -> str: + """Prefer an exact ID; otherwise require one unique prefix of 8+ characters. + + Exact opaque IDs may be shorter than eight characters. Duplicate IDs denote + one identity here; the host must still reject ambiguous capture locations. + """ + if not is_safe_session_id(reference): + raise SessionResolutionError("invalid_session_id", "session reference must be a safe ID") + matches: set[str] = set() + for candidate in candidates: + if candidate == reference: + return candidate + if candidate.startswith(reference): + matches.add(candidate) + if len(reference) < 8: + raise SessionResolutionError( + "invalid_session_id", "use an exact session ID or a prefix of at least 8 characters" + ) + if not matches: + raise SessionResolutionError("session_not_found", f"no session matches {reference!r}") + if len(matches) > 1: + shown = tuple(sorted(matches)[:5]) + raise SessionResolutionError( + "ambiguous_session", + f"{reference!r} matches {len(matches)} sessions; use a longer prefix or full ID. " + f"Candidates (up to 5): {', '.join(shown)}", + shown, + ) + return matches.pop() diff --git a/tests/test_session_ids.py b/tests/test_session_ids.py new file mode 100644 index 00000000..7a89b0d7 --- /dev/null +++ b/tests/test_session_ids.py @@ -0,0 +1,57 @@ +"""Host-independent session-reference matching.""" + +import pytest + +from context_intelligence import SessionResolutionError, resolve_session_id +from context_intelligence.session_ids import is_safe_session_id + + +def test_exact_id_wins_even_after_other_prefix_matches(): + assert resolve_session_id("session-a", iter(["session-ab", "session-a"])) == "session-a" + assert resolve_session_id("abc", ["abcd", "abc"]) == "abc" + + +def test_unique_prefix_resolves_opaque_and_child_ids(): + assert resolve_session_id("session-", ["other", "session-full"]) == "session-full" + child = "0000000000000000-abcdef1234567890_self" + assert resolve_session_id("0000000000000000-abcdef12", [child]) == child + + +def test_duplicate_ids_are_one_identity(): + assert resolve_session_id("abcdef12", ["abcdef123", "abcdef123"]) == "abcdef123" + + +def test_ambiguity_reports_bounded_sorted_candidates(): + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id("abcdef12", [f"abcdef12-{i}" for i in range(9, -1, -1)]) + assert caught.value.code == "ambiguous_session" + assert len(caught.value.candidates) == 5 + assert caught.value.candidates[0] == "abcdef12-0" + assert "10 sessions" in str(caught.value) + + +@pytest.mark.parametrize("reference", ["missing1", "ABCDEF12"]) +def test_missing_is_not_fuzzy_or_case_insensitive(reference): + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id(reference, ["abcdef123"]) + assert caught.value.code == "session_not_found" + + +def test_short_prefix_requires_more_characters(): + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id("abcdef", ["abcdef123"]) + assert caught.value.code == "invalid_session_id" + + +@pytest.mark.parametrize( + "reference", ["", ".", "..", "../abc", "a/b", "a\\b", "a*", "a?", "[a]", "a\0", "a b", "\n"] +) +def test_unsafe_references_rejected_without_consuming_candidates(reference): + def candidates(): + raise AssertionError("unsafe input must not trigger lookup") + yield "" + + assert not is_safe_session_id(reference) + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id(reference, candidates()) + assert caught.value.code == "invalid_session_id" From 810f070e899ea94d35c4268ec4660676bb24d597 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:32:58 -0700 Subject: [PATCH 2/9] fix(transcript): resolve short session IDs before replay Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 12 +- .../session_transcript_tool.py | 46 +++--- .../pyproject.toml | 4 +- .../tests/test_session_transcript_tool.py | 141 ++++++++++++++++++ .../uv.lock | 6 +- skills/transcript/SKILL.md | 27 +++- 6 files changed, 204 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 4fb2fab6..170ed2e0 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,16 @@ Two agents are included for querying session data: The `context-intelligence-navigation` behavior and the standalone `context-intelligence-transcript` behavior mount a user-invocable `/transcript` skill and the `session_transcript` tool. With no arguments, `/transcript` replays the current -session's native user/assistant capture. Pass an intent to use that transcript as source -material, or pass `--session ID[,ID...]` to target other session captures. The reader +session's native user/assistant capture. Use `/transcript abcdef12` for a unique +short ID (at least eight characters), or `/transcript FULL-ID` for an exact ID. +Pass an intent to use the current transcript as source material, or pass +`--session ID[,ID...]` to target multiple captures. Prefixes are resolved across +the mounted hook's local capture store; ambiguous matches ask for a longer ID, +never guess. Exact IDs take precedence. Use the returned full ID when paging. +Embedding hosts with a custom capture resolver own resolution in their storage. +The shared library exports `resolve_session_id(reference, candidates)` so hosts +and other tools can reuse the same exact-first, unique-prefix lookup without +reading transcripts or contacting a graph server. The reader preserves stored message strings, paginates only between messages, and does not call the graph or parse provider-raw payloads. Other hosts can provide their own capture resolver; the reusable library itself takes explicit event and metadata paths. Stored captures are diff --git a/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py b/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py index b0b8b045..5a0c27f5 100644 --- a/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py +++ b/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py @@ -13,12 +13,16 @@ read_native_transcript, render_native_transcript, ) +from context_intelligence.session_ids import ( + SessionResolutionError, + is_safe_session_id, + resolve_session_id, +) _CAPTURE_RESOLVER_CAPABILITY = "context_intelligence.capture_resolver" _HOOK_RESOLVER_CAPABILITY = "context_intelligence.hook_config_resolver" _MAX_SESSIONS_PER_REQUEST = 3 _MAX_TOTAL_CONTENT_CHARS = 100_000 -_GLOB_METACHARACTERS = frozenset("*?[]") class SessionTranscriptTool: @@ -52,7 +56,10 @@ def input_schema(self) -> dict[str, Any]: "type": "array", "items": {"type": "string"}, "maxItems": _MAX_SESSIONS_PER_REQUEST, - "description": "Optional session IDs. Omit to retrieve the calling session.", + "description": ( + "Optional full session IDs or unique prefixes (at least 8 characters). " + "Omit to retrieve the calling session. Use returned full IDs for pagination." + ), }, "after_event_line": {"type": "integer", "minimum": 0, "default": 0}, "after_event_lines": { @@ -97,19 +104,20 @@ def _coerce_locator(value: Any) -> CaptureLocator: @staticmethod def _find_capture_metadata(base_path: Path, session_id: str) -> list[Path]: - """Find literal session-directory matches without treating the ID as a glob.""" - matches: list[Path] = [] + """Match directory names only; do not open every capture to resolve a prefix.""" + candidates: dict[str, list[Path]] = {} for project_dir in base_path.iterdir(): sessions_dir = project_dir / "sessions" if not sessions_dir.is_dir(): continue for session_dir in sessions_dir.iterdir(): - if session_dir.name != session_id: + if not session_dir.name.startswith(session_id): continue metadata_path = session_dir / "context-intelligence" / "metadata.json" if metadata_path.is_file(): - matches.append(metadata_path) - return matches + candidates.setdefault(session_dir.name, []).append(metadata_path) + canonical_id = resolve_session_id(session_id, candidates) + return candidates[canonical_id] def _resolve_locator(self, session_id: str) -> CaptureLocator: """Resolve through an embedding host first, then the mounted CI hook.""" @@ -127,11 +135,16 @@ def _resolve_locator(self, session_id: str) -> CaptureLocator: directory = session_dir(session_id) if isinstance(directory, (Path, str)): locator = CaptureLocator.from_session_dir(directory) - if locator.metadata_path.is_file() and locator.events_path.is_file(): - return locator base_path = getattr(self._hook_resolver, "base_path", None) - if isinstance(base_path, Path): - matches = self._find_capture_metadata(base_path, session_id) + if isinstance(base_path, (Path, str)): + try: + matches = self._find_capture_metadata(Path(base_path), session_id) + except SessionResolutionError as exc: + raise NativeTranscriptError(exc.code, str(exc)) from exc + except OSError as exc: + raise NativeTranscriptError( + "capture_unavailable", f"cannot search local captures: {exc}" + ) from exc if len(matches) == 1: return CaptureLocator( events_path=matches[0].with_name("events.jsonl"), @@ -156,16 +169,7 @@ def _resolve_locator(self, session_id: str) -> CaptureLocator: @staticmethod def _is_safe_session_id(value: object) -> bool: - """Accept opaque IDs but never path components or glob expressions.""" - return ( - isinstance(value, str) - and bool(value) - and value not in {".", ".."} - and "/" not in value - and "\\" not in value - and "\0" not in value - and not _GLOB_METACHARACTERS.intersection(value) - ) + return is_safe_session_id(value) def _requested_sessions(self, input_data: dict[str, Any]) -> list[tuple[str, int]]: raw_ids = input_data.get("session_ids") diff --git a/modules/tool-context-intelligence-transcript/pyproject.toml b/modules/tool-context-intelligence-transcript/pyproject.toml index c3fe7365..44827b7d 100644 --- a/modules/tool-context-intelligence-transcript/pyproject.toml +++ b/modules/tool-context-intelligence-transcript/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "amplifier-module-tool-context-intelligence-transcript" -version = "0.1.0" +version = "0.1.1" description = "Bounded native Context Intelligence transcript retrieval" requires-python = ">=3.11" license = "MIT" dependencies = [ - "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@3e4887fdc58ac2299986f08afdd140865e9aa063", + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@2818e0519f1c59c71d6a5b53eee50592853f8d7e", ] [project.entry-points."amplifier.modules"] diff --git a/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py b/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py index 3581a825..d479db62 100644 --- a/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py +++ b/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py @@ -234,3 +234,144 @@ async def test_tool_rejects_content_limit_above_total_limit(tmp_path) -> None: assert result.success is False assert isinstance(result.error, dict) assert result.error["type"] == "invalid_request" + + +def _local_tool(base_path: Path) -> SessionTranscriptTool: + resolver = SimpleNamespace( + base_path=base_path, + session_dir=lambda sid: ( + base_path / "current-project" / "sessions" / sid / "context-intelligence" + ), + ) + coordinator = SimpleNamespace( + session_id="current-session", + get_capability=lambda name: ( + resolver if name == "context_intelligence.hook_config_resolver" else None + ), + ) + return SessionTranscriptTool(coordinator) + + +async def test_unique_prefix_in_another_workspace_returns_full_id_and_can_page(tmp_path): + sid = "abcdef12-1234-5678-9abc-123456789abc" + capture = _capture(tmp_path / "other-project" / "sessions", sid) + with (capture / "events.jsonl").open("a") as stream: + stream.write( + json.dumps( + { + "event": "prompt:complete", + "timestamp": "2026-09-15T10:00:01Z", + "data": {"response": "Second message"}, + } + ) + + "\n" + ) + tool = _local_tool(tmp_path) + first = await tool.execute({"session_ids": ["abcdef12"], "max_messages": 1}) + assert first.success + assert isinstance(first.output, dict) + page = first.output["sessions"][0] + assert page["session_id"] == sid + assert page["has_more"] + second = await tool.execute( + { + "session_ids": [page["session_id"]], + "after_event_lines": {page["session_id"]: page["next_after_event_line"]}, + } + ) + assert second.success + assert isinstance(second.output, dict) + assert second.output["sessions"][0]["messages"][0]["content"] == "Second message" + + +async def test_ambiguous_prefix_across_workspaces_never_prefers_current_workspace(tmp_path): + first_id, second_id = "abcdef12-first", "abcdef12-second" + _capture(tmp_path / "current-project" / "sessions", first_id) + _capture(tmp_path / "other-project" / "sessions", second_id) + result = await _local_tool(tmp_path).execute({"session_ids": ["abcdef12"]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "ambiguous_session" + assert first_id in result.error["message"] + assert second_id in result.error["message"] + assert "Hello" not in str(result.output) + + +async def test_missing_prefix_is_not_current_session_fallback(tmp_path): + _capture(tmp_path / "current-project" / "sessions", "current-session") + result = await _local_tool(tmp_path).execute({"session_ids": ["absent12"]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "session_not_found" + + +@pytest.mark.parametrize("reference", ["abcdef12", "abcdef12-full"]) +async def test_duplicate_capture_locations_are_ambiguous_even_with_same_full_id( + tmp_path, reference +): + for project in ("current-project", "other-project"): + _capture(tmp_path / project / "sessions", "abcdef12-full") + result = await _local_tool(tmp_path).execute({"session_ids": [reference]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "ambiguous_session" + assert "multiple capture roots" in result.error["message"] + + +async def test_two_prefixes_page_using_returned_full_ids(tmp_path): + ids = ["abcdef12-full", "abcdef34-full"] + for sid in ids: + _capture(tmp_path / "one" / "sessions", sid) + tool = _local_tool(tmp_path) + first = await tool.execute({"session_ids": ["abcdef12", "abcdef34"]}) + assert first.success + assert isinstance(first.output, dict) + full_ids = [page["session_id"] for page in first.output["sessions"]] + second = await tool.execute( + { + "session_ids": full_ids, + "after_event_lines": dict.fromkeys(full_ids, 1), + } + ) + assert second.success + assert isinstance(second.output, dict) + assert all(page["messages"] == [] for page in second.output["sessions"]) + + +async def test_custom_host_resolver_receives_prefix_unchanged(tmp_path): + capture = _capture(tmp_path, "abcdef12-full") + resolver = MagicMock( + return_value={ + "events_path": str(capture / "events.jsonl"), + "metadata_path": str(capture / "metadata.json"), + } + ) + coordinator = SimpleNamespace( + get_capability=lambda name: ( + SimpleNamespace(resolve_capture=resolver) + if name == "context_intelligence.capture_resolver" + else None + ) + ) + result = await SessionTranscriptTool(coordinator).execute({"session_ids": ["abcdef12"]}) + assert result.success + resolver.assert_called_once_with("abcdef12") + + +def test_prefix_lookup_reads_directory_names_not_capture_contents(tmp_path, monkeypatch): + capture = _capture(tmp_path / "one" / "sessions", "abcdef12-full") + monkeypatch.setattr(Path, "read_text", lambda *args, **kwargs: pytest.fail("content read")) + assert SessionTranscriptTool._find_capture_metadata(tmp_path, "abcdef12") == [ + capture / "metadata.json" + ] + + +async def test_unreadable_capture_store_returns_error_not_an_empty_success(tmp_path, monkeypatch): + def denied(*args): + raise PermissionError("cannot enumerate") + + monkeypatch.setattr(Path, "iterdir", denied) + result = await _local_tool(tmp_path).execute({"session_ids": ["abcdef12"]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "capture_unavailable" diff --git a/modules/tool-context-intelligence-transcript/uv.lock b/modules/tool-context-intelligence-transcript/uv.lock index 0bed56a8..e4ba3fc9 100644 --- a/modules/tool-context-intelligence-transcript/uv.lock +++ b/modules/tool-context-intelligence-transcript/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.3" -source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=3e4887fdc58ac2299986f08afdd140865e9aa063#3e4887fdc58ac2299986f08afdd140865e9aa063" } +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=2818e0519f1c59c71d6a5b53eee50592853f8d7e#2818e0519f1c59c71d6a5b53eee50592853f8d7e" } dependencies = [ { name = "azure-identity" }, ] @@ -32,7 +32,7 @@ wheels = [ [[package]] name = "amplifier-module-tool-context-intelligence-transcript" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "amplifier-bundle-context-intelligence" }, @@ -48,7 +48,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=3e4887fdc58ac2299986f08afdd140865e9aa063" }] +requires-dist = [{ name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=2818e0519f1c59c71d6a5b53eee50592853f8d7e" }] [package.metadata.requires-dev] dev = [ diff --git a/skills/transcript/SKILL.md b/skills/transcript/SKILL.md index 67f55b0b..c9979027 100644 --- a/skills/transcript/SKILL.md +++ b/skills/transcript/SKILL.md @@ -1,7 +1,7 @@ --- name: transcript description: Retrieve the prior user and assistant conversation verbatim from the current Context Intelligence capture, or named sessions. Use when a user asks to replay, quote, review, or act on a transcript. -version: 1.0.0 +version: 1.1.0 license: MIT user-invocable: true compatibility: Amplifier with the session_transcript tool mounted @@ -16,6 +16,12 @@ Use `session_transcript`; never read `events.jsonl` directly. Interpret `$ARGUMENTS` as follows: +- **`ID` or `ID -- `:** a bare full session ID or a hex ID prefix of + at least 8 characters (for example, `/transcript abcdef12`) selects that + session. Pass it unchanged in `session_ids`; the tool resolves it. Do not + ask for the full ID first or treat it as an instruction about the current + session. With no intent, replay verbatim; with intent, retrieve first, then + perform that intent. - **No arguments:** call `session_transcript` with no `session_ids`. Return the complete role-marked transcript pages verbatim. Do not summarize or add commentary. @@ -28,10 +34,23 @@ Interpret `$ARGUMENTS` as follows: - **`--session ID[,ID...] -- `:** retrieve all named sessions first, then perform the intent using those transcripts. +Bare opaque ID tokens containing a hyphen, underscore, or digit also select a +session; for other non-UUID references use explicit `--session`, which accepts +unique prefixes and exact opaque IDs. +If natural language explicitly names a session, pass that ID or prefix to the +tool as well. Ordinary intent text such as `summarize` still targets the +current session; it is not a session ID. + +On an ambiguous prefix, show the tool's candidates and ask for a longer prefix +or full ID. On a missing session, report that failure. Never choose a candidate, +retry against the current session, or delegate to an agent to guess an ID. + For a single session, keep calling `session_transcript` with its returned -`after_event_line` while `has_more` is true. For multiple sessions, retrieve and -page one session at a time; use the returned per-session cursors when issuing a -batched follow-up. A page boundary is normal; preserve the tool's +full `session_id` and `next_after_event_line` as the next `after_event_line` +while `has_more` is true. For multiple sessions, retrieve and +page one session at a time. For a batched follow-up, replace the original prefixes +in `session_ids` with returned full IDs and use those same full IDs as +`after_event_lines` keys. A page boundary is normal; preserve the tool's `[MORE MESSAGES AVAILABLE ...]` marker between pages. Do not hide capture issues, infer missing messages, use graph reconstruction, or fall back to raw provider events. From 9abc62d23e69ccbcffb6329be98218598d62d4ff Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:36:38 -0700 Subject: [PATCH 3/9] fix(transcript): execute requested recall without redundant confirmation Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- skills/transcript/SKILL.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/skills/transcript/SKILL.md b/skills/transcript/SKILL.md index c9979027..3de53d12 100644 --- a/skills/transcript/SKILL.md +++ b/skills/transcript/SKILL.md @@ -11,6 +11,13 @@ compatibility: Amplifier with the session_transcript tool mounted Use `session_transcript`; never read `events.jsonl` directly. +Invoking this skill is a request to retrieve the transcript, not to explain how +to retrieve it. Call `session_transcript` in this turn using the arguments below. +Do not stop after loading the skill, present a proposed tool call, or ask "shall +I retrieve it?" The user's request already authorizes this read. Only ask for +ID clarification after the tool reports ambiguity; honor any actual tool +permission gate or explicit user restriction. + > **Sensitive content:** stored captures are replayed verbatim and can contain > sensitive content. The logging hook's JSON sanitization is not redaction. From 9f30868c4a565d134e407df4544377e7a0121e40 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:04:59 -0700 Subject: [PATCH 4/9] fix(query): refresh stale shared-library lock Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- modules/tool-context-intelligence-query/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/tool-context-intelligence-query/uv.lock b/modules/tool-context-intelligence-query/uv.lock index c499924a..60f82de4 100644 --- a/modules/tool-context-intelligence-query/uv.lock +++ b/modules/tool-context-intelligence-query/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.3" -source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#ad4f2d4a5054376abecb6a59b92da23d5bf53100" } +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#19da7245f0519fd547e5285ab22638dab57fe17b" } dependencies = [ { name = "azure-identity" }, ] From bb1cc8c1e1b10b14278967ee8fd53d480ee48e3e Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:05:03 -0700 Subject: [PATCH 5/9] fix(validation): run full checks in the prepared CLI environment Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- AGENTS.md | 16 ++-- CONTRIBUTING.md | 7 +- scripts/validate-full.sh | 67 +++++++++----- tests/test_validate_full_launcher.py | 133 +++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 tests/test_validate_full_launcher.py diff --git a/AGENTS.md b/AGENTS.md index a36803ca..3e43801f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,13 +39,15 @@ scripts/validate-full.sh # validates this repo scripts/validate-full.sh # or another bundle repo ``` -It builds a throwaway `uv` venv with `hatchling` + `amplifier-foundation` + `amplifier-core` + -`pyyaml`, puts it first on `PATH`, and runs `validate-bundle-repo` so its `python3` resolves to an -interpreter that has the deps → `validation_mode: full`. - -**Last full run: ✅ PASS** — 10/10 bundles clean, all hygiene/structure/placement/freshness gates -green, the lone mode "error" confirmed a false positive (name collision). Only the build *dry-run* -is skipped (no `pip wheel` in the venv); the wheels build cleanly under `uv build`. +It builds a fresh `uv` venv containing the pinned public CLI/Foundation, a prebuilt Core wheel, +`hatchling`, `pyyaml`, and `pip`, then runs **that venv's CLI**. PATH alone is insufficient: +the CLI supplies its own interpreter to recipe shell steps. The venv is removed on exit. +Set `CI_VALIDATE_RECIPE` to an explicit recipe path if multiple Foundation caches exist; +`CI_VALIDATE_VENV`, if supplied, must be a new directory. No validator findings are suppressed. + +Full mode includes the actual `pip wheel` build check. Record each run's mode, build result, +and findings; a successful recipe exit is not itself a validation PASS. Review the documented +mode-name false positive above without suppressing other findings. ## Testing & what "done" looks like diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed25405b..af785d0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,8 +62,11 @@ Before opening a PR that touches bundle structure, run the repo's **full** valid scripts/validate-full.sh ``` -It builds a throwaway `uv` venv with `hatchling` + `amplifier-foundation` + -`amplifier-core` so the validator runs at `validation_mode: full`. The lone +It runs a pinned public CLI from a fresh `uv` venv with Foundation, a prebuilt +Core wheel, `hatchling`, `pyyaml`, and `pip`, so the validator runs at +`validation_mode: full` without compiling Core. The venv is removed on exit. +If multiple Foundation recipes are cached, select one with `CI_VALIDATE_RECIPE`. +The lone mode-advertising **ERROR** it reports is a **documented FALSE POSITIVE** (a name collision — see `AGENTS.md`); **do not "fix" it** by advertising the internal mode or deleting path/skill references. diff --git a/scripts/validate-full.sh b/scripts/validate-full.sh index d0af718b..3b26e583 100755 --- a/scripts/validate-full.sh +++ b/scripts/validate-full.sh @@ -11,15 +11,9 @@ # for a behaviour split: BundleRegistry resolution of the layered includes, and the # package build check. # -# This script builds a throwaway uv venv that HAS those deps, puts its `bin` first -# on PATH, and runs the recipe — so the recipe's `python3` resolves to an -# interpreter that can `import amplifier_foundation` and `import hatchling`, which -# flips the run to `validation_mode: full`. -# -# (This is the uv-based equivalent of the recipe's own documented -# `uvx --with hatchling --with amplifier-foundation amplifier tool invoke ...` -# one-liner; the venv form is used because the recipe shells out to `python3`, -# so the deps must live on the PATH `python3`, not just in a uvx tool env.) +# The CLI sets AMPLIFIER_PYTHON for recipe shell steps. PATH alone cannot move +# those steps into another venv: the CLI itself must run from the prepared venv. +# Use a public Core wheel, not a Rust source build, for this bundle's validation. # # USAGE # ----- @@ -27,36 +21,59 @@ # REPO_PATH defaults to this bundle's repo root. # # ENV -# CI_VALIDATE_VENV override the venv location (default: $TMPDIR/ci-validate-venv) +# CI_VALIDATE_VENV optional NEW venv directory; never overwrite an existing one +# CI_VALIDATE_RECIPE explicit recipe path if more than one Foundation is cached # -# Requires: uv, and the `amplifier` CLI on PATH, with the amplifier-foundation -# bundle present in ~/.amplifier/cache (it ships the recipe). +# Requires: uv and a cached Foundation recipe (or CI_VALIDATE_RECIPE). +# A fresh venv is removed on exit. The recipe's report, including failures and +# known false positives, is returned unchanged; exit 0 alone is not a PASS. # set -euo pipefail REPO_PATH="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" -VENV="${CI_VALIDATE_VENV:-${TMPDIR:-/tmp}/ci-validate-venv}" - -echo ">> building deps venv: $VENV" -uv venv --python 3.11 "$VENV" >/dev/null -uv pip install --python "$VENV/bin/python" --quiet \ - hatchling pyyaml \ - "amplifier-core @ git+https://github.com/microsoft/amplifier-core@main" \ - "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@main" +REPO_PATH="$(cd "$REPO_PATH" && pwd)" # Locate the foundation validate-bundle-repo recipe in the Amplifier cache. # (The bare `amplifier tool invoke` CLI does not resolve the `foundation:` recipe # namespace, so we pass the cached recipe by absolute path.) -RECIPE="$(ls -1 "${HOME}/.amplifier/cache/"amplifier-foundation-*/recipes/validate-bundle-repo.yaml 2>/dev/null | head -1 || true)" +RECIPE="${CI_VALIDATE_RECIPE:-}" if [[ -z "$RECIPE" ]]; then - echo "!! validate-bundle-repo.yaml not found under ~/.amplifier/cache/amplifier-foundation-*/recipes/" >&2 - echo " Ensure the amplifier-foundation bundle is installed/cached, then retry." >&2 + shopt -s nullglob + recipes=("${HOME}/.amplifier/cache/"amplifier-foundation-*/recipes/validate-bundle-repo.yaml) + if [[ ${#recipes[@]} -ne 1 ]]; then + echo "!! Expected one cached validation recipe; found ${#recipes[@]}. Set CI_VALIDATE_RECIPE." >&2 + exit 1 + fi + RECIPE="${recipes[0]}" +fi +if [[ ! -f "$RECIPE" || ! -r "$RECIPE" ]]; then + echo "!! Validation recipe is not a readable file: $RECIPE" >&2 exit 1 fi +if [[ -n "${CI_VALIDATE_VENV:-}" ]]; then + VENV="$CI_VALIDATE_VENV" + # mkdir refuses existing directories and symlinks before uv can touch them. + mkdir -- "$VENV" +else + VENV="$(mktemp -d "${TMPDIR:-/tmp}/ci-validate-venv.XXXXXXXX")" +fi +trap 'rm -rf -- "$VENV"' EXIT +export PYTHONNOUSERSITE=1 + +echo ">> building isolated validation runtime: $VENV" +uv venv --python 3.11 "$VENV" >/dev/null +uv pip install --python "$VENV/bin/python" --only-binary amplifier-core --quiet \ + pip hatchling pyyaml "amplifier-core==1.6.1" \ + "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@7ad00b359fd5c2ac3ee98436b1b3bccabe6e909d" \ + "amplifier-app-cli @ git+https://github.com/microsoft/amplifier-app-cli@14dc68eba05bf65b8c6dea28c3a2db93daa12d38" +"$VENV/bin/python" -c 'import pip, hatchling, yaml, amplifier_core, amplifier_foundation' +# JSON encoding preserves spaces, quotes, and backslashes in the target path. +CONTEXT="$("$VENV/bin/python" -c 'import json,sys; print(json.dumps({"repo_path": sys.argv[1], "enhance_diagrams": "false"}))' "$REPO_PATH")" + echo ">> recipe: $RECIPE" echo ">> repo: $REPO_PATH" echo ">> running validate-bundle-repo in FULL mode ..." -PATH="$VENV/bin:$PATH" amplifier tool invoke recipes operation=execute \ +PATH="$VENV/bin:$PATH" "$VENV/bin/amplifier" tool invoke recipes operation=execute \ recipe_path="$RECIPE" \ - context="{\"repo_path\":\"$REPO_PATH\"}" + context="$CONTEXT" diff --git a/tests/test_validate_full_launcher.py b/tests/test_validate_full_launcher.py new file mode 100644 index 00000000..4a838078 --- /dev/null +++ b/tests/test_validate_full_launcher.py @@ -0,0 +1,133 @@ +"""Launcher wiring checks; real recipe validation remains the DTU gate.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +SCRIPT = Path(__file__).parents[1] / "scripts" / "validate-full.sh" + + +@pytest.fixture +def launcher(tmp_path): + home = tmp_path / "home" + home.mkdir() + recipe = home / ".amplifier/cache/amplifier-foundation-one/recipes/validate-bundle-repo.yaml" + recipe.parent.mkdir(parents=True) + recipe.write_text("# test recipe") + tools = tmp_path / "tools" + tools.mkdir() + log = tmp_path / "calls.jsonl" + # Outbound spies exercise the actual shell, argv, and failure propagation. + # They do not stand in for a successful full validation result. + uv = tools / "uv" + uv.write_text( + f"#!{sys.executable}\n" + "import json,os,pathlib,sys\n" + "with open(os.environ['CALL_LOG'], 'a') as f:\n" + " f.write(json.dumps(sys.argv[1:])+'\\n')\n" + "if sys.argv[1]=='venv':\n" + " b=pathlib.Path(sys.argv[-1])/'bin'; b.mkdir()\n" + " (b/'python').write_text('#!/bin/sh\\n" + 'if [ "$2" = "import pip, hatchling, yaml, amplifier_core, amplifier_foundation" ]; ' + f'then exit 0; fi\\nexec {sys.executable} "$@"\\n\')\n' + " (b/'python').chmod(0o755)\n" + " (b/'amplifier').write_text('#!/bin/sh\\n" + f"exec {sys.executable} \"'+os.environ['CLI_SPY']+'\" \"$@\"\\n')\n" + " (b/'amplifier').chmod(0o755)\n" + "else:\n" + " sys.exit(int(os.environ.get('INSTALL_STATUS','0')))\n" + ) + uv.chmod(0o755) + spy = tmp_path / "cli_spy.py" + spy.write_text( + "import json,os,sys\n" + "with open(os.environ['CLI_RESULT'],'w') as f:\n" + " json.dump({'args':sys.argv[1:], 'no_user_site':os.environ.get('PYTHONNOUSERSITE')},f)\n" + "sys.exit(int(os.environ.get('RECIPE_STATUS','0')))\n" + ) + ambient = tools / "amplifier" + ambient.write_text("#!/bin/sh\nexit 98\n") + ambient.chmod(0o755) + env = { + **os.environ, + "HOME": str(home), + "TMPDIR": str(tmp_path), + "PATH": f"{tools}:{os.environ['PATH']}", + "CALL_LOG": str(log), + "CLI_SPY": str(spy), + "CLI_RESULT": str(tmp_path / "cli-result.json"), + } + env.pop("CI_VALIDATE_VENV", None) + env.pop("CI_VALIDATE_RECIPE", None) + return tmp_path, recipe, env + + +def _run(launcher, **overrides): + tmp_path, _, env = launcher + repo = tmp_path / 'repo with "quotes"' + repo.mkdir(exist_ok=True) + return subprocess.run( + ["bash", str(SCRIPT), str(repo)], + env={**env, **overrides}, + capture_output=True, + text=True, + timeout=15, + ) + + +@pytest.mark.parametrize("status", ["0", "7"]) +def test_runs_venv_cli_and_preserves_status_and_json_paths(launcher, status): + tmp_path, _, env = launcher + result = _run(launcher, RECIPE_STATUS=status) + assert result.returncode == int(status), result.stderr + calls = [json.loads(line) for line in Path(env["CALL_LOG"]).read_text().splitlines()] + assert "--only-binary" in calls[1] + assert "amplifier-core==1.6.1" in calls[1] + assert not any("amplifier-core@" in arg for arg in calls[1]) + response = json.loads(Path(env["CLI_RESULT"]).read_text()) + context = json.loads(next(arg[8:] for arg in response["args"] if arg.startswith("context="))) + assert context["repo_path"] == str(tmp_path / 'repo with "quotes"') + assert response["no_user_site"] == "1" + assert not Path(calls[0][-1]).exists() + + +def test_refuses_existing_venv_without_touching_it(launcher): + tmp_path, _, env = launcher + existing = tmp_path / "already-exists" + existing.mkdir() + marker = existing / "keep" + marker.write_text("untouched") + assert _run(launcher, CI_VALIDATE_VENV=str(existing)).returncode != 0 + assert marker.read_text() == "untouched" + assert not Path(env["CALL_LOG"]).exists() + + +def test_multiple_recipes_require_explicit_selection(launcher): + _, recipe, env = launcher + other = Path(env["HOME"]) / ".amplifier/cache/amplifier-foundation-two/recipes" + other.mkdir(parents=True) + (other / recipe.name).write_text("# other") + assert _run(launcher).returncode != 0 + assert not Path(env["CALL_LOG"]).exists() + assert _run(launcher, CI_VALIDATE_RECIPE=str(recipe)).returncode == 0 + + +def test_missing_recipe_fails_before_install(launcher): + _, recipe, env = launcher + recipe.unlink() + assert _run(launcher, CI_VALIDATE_RECIPE=str(recipe)).returncode != 0 + assert not Path(env["CALL_LOG"]).exists() + + +def test_install_failure_cleans_up_and_does_not_invoke_cli(launcher): + _, _, env = launcher + assert _run(launcher, INSTALL_STATUS="9").returncode == 9 + calls = [json.loads(line) for line in Path(env["CALL_LOG"]).read_text().splitlines()] + assert not Path(calls[0][-1]).exists() + assert not Path(env["CLI_RESULT"]).exists() From 10f348a9eb531c64a2ec788c40c083d4ea18ec21 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:38:16 -0700 Subject: [PATCH 6/9] fix(validation): override the CLI Foundation dependency explicitly Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- scripts/validate-full.sh | 9 +++++++-- tests/test_validate_full_launcher.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/validate-full.sh b/scripts/validate-full.sh index 3b26e583..dcba411f 100755 --- a/scripts/validate-full.sh +++ b/scripts/validate-full.sh @@ -63,9 +63,14 @@ export PYTHONNOUSERSITE=1 echo ">> building isolated validation runtime: $VENV" uv venv --python 3.11 "$VENV" >/dev/null -uv pip install --python "$VENV/bin/python" --only-binary amplifier-core --quiet \ - pip hatchling pyyaml "amplifier-core==1.6.1" \ +# The public CLI declares Foundation@main; override that URL rather than +# supplying a second, conflicting direct requirement. +printf '%s\n' \ "amplifier-foundation @ git+https://github.com/microsoft/amplifier-foundation@7ad00b359fd5c2ac3ee98436b1b3bccabe6e909d" \ + > "$VENV/overrides.txt" +uv pip install --python "$VENV/bin/python" --only-binary amplifier-core \ + --overrides "$VENV/overrides.txt" --quiet \ + pip hatchling pyyaml "amplifier-core==1.6.1" \ "amplifier-app-cli @ git+https://github.com/microsoft/amplifier-app-cli@14dc68eba05bf65b8c6dea28c3a2db93daa12d38" "$VENV/bin/python" -c 'import pip, hatchling, yaml, amplifier_core, amplifier_foundation' # JSON encoding preserves spaces, quotes, and backslashes in the target path. diff --git a/tests/test_validate_full_launcher.py b/tests/test_validate_full_launcher.py index 4a838078..d50a3356 100644 --- a/tests/test_validate_full_launcher.py +++ b/tests/test_validate_full_launcher.py @@ -41,6 +41,8 @@ def launcher(tmp_path): f"exec {sys.executable} \"'+os.environ['CLI_SPY']+'\" \"$@\"\\n')\n" " (b/'amplifier').chmod(0o755)\n" "else:\n" + " p=pathlib.Path(sys.argv[sys.argv.index('--overrides')+1])\n" + " pathlib.Path(os.environ['OVERRIDE_COPY']).write_text(p.read_text())\n" " sys.exit(int(os.environ.get('INSTALL_STATUS','0')))\n" ) uv.chmod(0o755) @@ -62,6 +64,7 @@ def launcher(tmp_path): "CALL_LOG": str(log), "CLI_SPY": str(spy), "CLI_RESULT": str(tmp_path / "cli-result.json"), + "OVERRIDE_COPY": str(tmp_path / "override-copy.txt"), } env.pop("CI_VALIDATE_VENV", None) env.pop("CI_VALIDATE_RECIPE", None) @@ -88,6 +91,11 @@ def test_runs_venv_cli_and_preserves_status_and_json_paths(launcher, status): assert result.returncode == int(status), result.stderr calls = [json.loads(line) for line in Path(env["CALL_LOG"]).read_text().splitlines()] assert "--only-binary" in calls[1] + assert "--overrides" in calls[1] + assert ( + "amplifier-foundation@7ad00b359fd5c2ac3ee98436b1b3bccabe6e909d" + in Path(env["OVERRIDE_COPY"]).read_text() + ) assert "amplifier-core==1.6.1" in calls[1] assert not any("amplifier-core@" in arg for arg in calls[1]) response = json.loads(Path(env["CLI_RESULT"]).read_text()) From f22da98ed9ff11180a2f7a1318bd30fed5986668 Mon Sep 17 00:00:00 2001 From: colombod Date: Fri, 18 Sep 2026 23:10:53 +0000 Subject: [PATCH 7/9] fix(transcript): prevent prefix-based session lookup from returning wrong session Prefix-based session ID lookup could return a different session's transcript with success=True when the requested ID was a prefix of another session's capture directory name. Root cause: spawned sub-agent capture directories are named `{parent_session_id}_{agent}`, making every session ID a prefix of captures it spawned. The metadata lookup only treated directories as candidates after `metadata.json` was written, which happens lazily on the first event. A session still initializing would drop out of the candidate set, allowing one of its own sub-agents to win instead. This affected both the no-argument `/transcript` path (current session) and explicit prefix lookups. Two guards were added: - An exactly-named capture directory is now recognized as the requested session even before metadata exists (reported as capture_unavailable) - An ID whose only matches are derived sub-agent captures is reported as session_not_found, naming those sub-agents rather than silently replaying one The existing prefix-scan logic is preserved, maintaining the duplicate-capture-root ambiguity guarantee. Fixes: five regression tests added; four were failing before this change. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../session_transcript_tool.py | 27 +++++++ .../tests/test_session_transcript_tool.py | 76 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py b/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py index 5a0c27f5..49a5edc7 100644 --- a/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py +++ b/modules/tool-context-intelligence-transcript/amplifier_module_tool_context_intelligence_transcript/session_transcript_tool.py @@ -23,6 +23,10 @@ _HOOK_RESOLVER_CAPABILITY = "context_intelligence.hook_config_resolver" _MAX_SESSIONS_PER_REQUEST = 3 _MAX_TOTAL_CONTENT_CHARS = 100_000 +# A spawned sub-agent's capture directory is named `{parent_session_id}_{agent}`, +# so every session ID is a strict prefix of every capture it spawned. Prefix +# lookup must never let a derived capture stand in for the session that names it. +_DERIVED_CAPTURE_MARK = "_" class SessionTranscriptTool: @@ -106,6 +110,7 @@ def _coerce_locator(value: Any) -> CaptureLocator: def _find_capture_metadata(base_path: Path, session_id: str) -> list[Path]: """Match directory names only; do not open every capture to resolve a prefix.""" candidates: dict[str, list[Path]] = {} + matched_names: set[str] = set() for project_dir in base_path.iterdir(): sessions_dir = project_dir / "sessions" if not sessions_dir.is_dir(): @@ -113,9 +118,31 @@ def _find_capture_metadata(base_path: Path, session_id: str) -> list[Path]: for session_dir in sessions_dir.iterdir(): if not session_dir.name.startswith(session_id): continue + matched_names.add(session_dir.name) metadata_path = session_dir / "context-intelligence" / "metadata.json" if metadata_path.is_file(): candidates.setdefault(session_dir.name, []).append(metadata_path) + + # An exactly-named capture directory IS the requested session, even before + # its metadata.json exists -- the logging handler writes that file lazily, + # on the first event. Reporting the gap keeps a session whose capture is + # still initialising from being answered with one of its own sub-agents. + if session_id in matched_names and session_id not in candidates: + raise NativeTranscriptError( + "capture_unavailable", + f"session {session_id!r} has a capture directory but no readable " + "metadata.json; its capture is incomplete or still initialising", + ) + derived = sorted( + name for name in candidates if name.startswith(session_id + _DERIVED_CAPTURE_MARK) + ) + if derived and len(derived) == len(candidates): + raise NativeTranscriptError( + "session_not_found", + f"session {session_id!r} has no capture of its own; only sub-agent " + f"sessions derived from it were captured. Candidates (up to 5): " + f"{', '.join(derived[:5])}", + ) canonical_id = resolve_session_id(session_id, candidates) return candidates[canonical_id] diff --git a/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py b/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py index d479db62..96a869c4 100644 --- a/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py +++ b/modules/tool-context-intelligence-transcript/tests/test_session_transcript_tool.py @@ -236,7 +236,7 @@ async def test_tool_rejects_content_limit_above_total_limit(tmp_path) -> None: assert result.error["type"] == "invalid_request" -def _local_tool(base_path: Path) -> SessionTranscriptTool: +def _local_tool(base_path: Path, current_session_id: str = "current-session"): resolver = SimpleNamespace( base_path=base_path, session_dir=lambda sid: ( @@ -244,7 +244,7 @@ def _local_tool(base_path: Path) -> SessionTranscriptTool: ), ) coordinator = SimpleNamespace( - session_id="current-session", + session_id=current_session_id, get_capability=lambda name: ( resolver if name == "context_intelligence.hook_config_resolver" else None ), @@ -375,3 +375,75 @@ def denied(*args): assert not result.success assert isinstance(result.error, dict) assert result.error["type"] == "capture_unavailable" + + +def _capture_without_metadata(sessions_root: Path, session_id: str) -> Path: + """A capture whose metadata.json has not been written yet. + + The logging handler creates metadata.json lazily, on the first event, so + this is the real state of a session that has just started. + """ + capture_dir = sessions_root / session_id / "context-intelligence" + capture_dir.mkdir(parents=True) + (capture_dir / "events.jsonl").write_text("", encoding="utf-8") + return capture_dir + + +async def test_initialising_capture_never_replaced_by_derived_sub_agent(tmp_path): + """A session ID prefixes every `{id}_{agent}` sub-agent capture it spawned.""" + parent = "abcdef12-1234-5678-9abc-123456789abc" + _capture_without_metadata(tmp_path / "current-project" / "sessions", parent) + _capture(tmp_path / "current-project" / "sessions", f"{parent}_foundation-explorer") + result = await _local_tool(tmp_path).execute({"session_ids": [parent]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "capture_unavailable" + assert "Hello" not in str(result.output) + + +async def test_current_session_never_replaced_by_its_own_sub_agent(tmp_path): + """The no-argument `/transcript` path resolves the same way.""" + current = "current-session" + _capture_without_metadata(tmp_path / "current-project" / "sessions", current) + _capture(tmp_path / "current-project" / "sessions", f"{current}_foundation-explorer") + result = await _local_tool(tmp_path, current_session_id=current).execute({}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "capture_unavailable" + assert "Hello" not in str(result.output) + + +async def test_initialising_capture_is_not_ambiguous_between_its_sub_agents(tmp_path): + """Two sub-agents must not turn an exact, valid ID into an ambiguity error.""" + parent = "abcdef12-1234-5678-9abc-123456789abc" + sessions = tmp_path / "current-project" / "sessions" + _capture_without_metadata(sessions, parent) + _capture(sessions, f"{parent}_foundation-explorer") + _capture(sessions, f"{parent}_foundation-git-ops") + result = await _local_tool(tmp_path).execute({"session_ids": [parent]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "capture_unavailable" + + +async def test_id_with_only_derived_captures_is_reported_not_substituted(tmp_path): + """No capture of its own: name the derived sessions instead of replaying one.""" + parent = "abcdef12-1234-5678-9abc-123456789abc" + child = f"{parent}_foundation-explorer" + _capture(tmp_path / "current-project" / "sessions", child) + result = await _local_tool(tmp_path).execute({"session_ids": [parent]}) + assert not result.success + assert isinstance(result.error, dict) + assert result.error["type"] == "session_not_found" + assert child in result.error["message"] + assert "Hello" not in str(result.output) + + +async def test_prefix_stopping_short_of_the_derivation_mark_still_resolves(tmp_path): + """A genuine prefix is not mistaken for a parent ID with derived captures.""" + child = "abcdef12-1234-5678-9abc-123456789abc_foundation-explorer" + _capture(tmp_path / "current-project" / "sessions", child) + result = await _local_tool(tmp_path).execute({"session_ids": ["abcdef12-1234"]}) + assert result.success, result.error + assert isinstance(result.output, dict) + assert result.output["sessions"][0]["session_id"] == child From a3688ff83bbf3dc16d36434008e45e9a70a571c9 Mon Sep 17 00:00:00 2001 From: colombod Date: Fri, 18 Sep 2026 23:11:00 +0000 Subject: [PATCH 8/9] fix(session-ids): improve ambiguity error message with divergence position The ambiguity error message previously said "use a longer prefix or full ID", which was unactionable when matches shared a long leading run. Because spawned sub-agent IDs begin with a zero-padded parent block, a real capture store could have 4456 sessions matching an 8-character prefix, with the distinguishing character at position 18. The message now computes the shared prefix length across all matches and reports the exact divergence point: "all of them share their first 17 characters, so supply at least 18 characters or an exact full ID". Also handles the case where one match is a strict prefix of another: no longer prefix can separate them, so only an exact full ID can select it. Unchanged: the 8-character safety floor, ambiguous_session error code, and the bounded 5-sorted-candidate contract. Fixes: three regression tests added; all were failing before this change. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/session_ids.py | 29 +++++++++++++++++++++++++- tests/test_session_ids.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/context_intelligence/session_ids.py b/context_intelligence/session_ids.py index 7b3c5027..4e547812 100644 --- a/context_intelligence/session_ids.py +++ b/context_intelligence/session_ids.py @@ -53,8 +53,35 @@ def resolve_session_id(reference: str, candidates: Iterable[str]) -> str: shown = tuple(sorted(matches)[:5]) raise SessionResolutionError( "ambiguous_session", - f"{reference!r} matches {len(matches)} sessions; use a longer prefix or full ID. " + f"{reference!r} matches {len(matches)} sessions; {_disambiguation_hint(matches)} " f"Candidates (up to 5): {', '.join(shown)}", shown, ) return matches.pop() + + +def _shared_prefix_length(matches: set[str]) -> int: + """Count the leading characters every match has in common.""" + shortest = min(matches, key=len) + for index, character in enumerate(shortest): + if any(match[index] != character for match in matches): + return index + return len(shortest) + + +def _disambiguation_hint(matches: set[str]) -> str: + """Say how much more is needed, not merely that more is needed. + + A flat "use a longer prefix" is unactionable when the matches share a long + run of leading characters -- a spawned sub-agent ID begins with a + zero-padded parent block, so the separating character can sit well past the + eight-character minimum. Report where the matches actually diverge. + """ + shared = _shared_prefix_length(matches) + if shared == len(min(matches, key=len)): + # One match is a prefix of another, so no longer prefix can separate them. + return "one is a prefix of another, so only an exact full ID can select it." + return ( + f"all of them share their first {shared} characters, " + f"so supply at least {shared + 1} characters or an exact full ID." + ) diff --git a/tests/test_session_ids.py b/tests/test_session_ids.py index 7a89b0d7..3e66e430 100644 --- a/tests/test_session_ids.py +++ b/tests/test_session_ids.py @@ -55,3 +55,35 @@ def candidates(): with pytest.raises(SessionResolutionError) as caught: resolve_session_id(reference, candidates()) assert caught.value.code == "invalid_session_id" + + +def test_ambiguity_names_the_position_where_candidates_diverge(): + """Sub-agent IDs share a zero-padded block, so '8 characters' is not the answer.""" + candidates = [f"0000000000000000-abcdef123456789{n}_self" for n in range(4)] + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id("00000000", candidates) + assert caught.value.code == "ambiguous_session" + message = str(caught.value) + # The matches are identical through index 31; character 33 separates them. + assert "first 32 characters" in message + assert "at least 33" in message + assert len(caught.value.candidates) == 4 + + +def test_divergence_hint_is_bounded_and_keeps_the_candidate_contract(): + candidates = [f"abcdef12-{n}" for n in range(9, -1, -1)] + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id("abcdef12", candidates) + assert len(caught.value.candidates) == 5 + assert caught.value.candidates[0] == "abcdef12-0" + assert "first 9 characters" in str(caught.value) + assert "at least 10" in str(caught.value) + + +def test_divergence_hint_admits_when_only_an_exact_id_can_disambiguate(): + """One candidate is a strict prefix of the other: no longer prefix can separate them.""" + with pytest.raises(SessionResolutionError) as caught: + resolve_session_id("abcdef12", ["abcdef123", "abcdef1234"]) + assert caught.value.code == "ambiguous_session" + assert "only an exact full ID" in str(caught.value) + assert "supply at least" not in str(caught.value) From 48e80e74f66bc78580fda4b0396cba4d2d892977 Mon Sep 17 00:00:00 2001 From: colombod Date: Fri, 18 Sep 2026 23:11:07 +0000 Subject: [PATCH 9/9] docs(transcript): clarify session ID shapes and ambiguity handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated skill documentation to reflect the two preceding fixes. Skill changes: - Clarified ID shape guidance: "8+ hex characters, a full UUID, or the zero-padded sub-agent form" replace the vague "containing a hyphen, underscore, or digit" which incorrectly swallowed intent text like `/transcript chapter-2` - Specified that ambiguous tokens resolve toward intent, because visible intent errors are recoverable while wrong lookups just fail - Added guidance to relay the divergence position reported by the tool - Specified that capture_unavailable and derived-sub-agent session_not_found errors should be relayed rather than substituted Skill version: 1.1.0 → 1.2.0 README updates: - Stale ambiguity-handling paragraph was updated to match current code behavior Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 8 ++++++-- skills/transcript/SKILL.md | 30 +++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 170ed2e0..cbe154db 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,12 @@ session's native user/assistant capture. Use `/transcript abcdef12` for a unique short ID (at least eight characters), or `/transcript FULL-ID` for an exact ID. Pass an intent to use the current transcript as source material, or pass `--session ID[,ID...]` to target multiple captures. Prefixes are resolved across -the mounted hook's local capture store; ambiguous matches ask for a longer ID, -never guess. Exact IDs take precedence. Use the returned full ID when paging. +the mounted hook's local capture store; ambiguous matches name the character +position where the candidates diverge and ask for at least that many, never +guess. Exact IDs take precedence, including over the derived `_` +sub-agent captures that every session ID prefixes: a session whose capture is +still initialising is reported, never answered with one of its own sub-agents. +Use the returned full ID when paging. Embedding hosts with a custom capture resolver own resolution in their storage. The shared library exports `resolve_session_id(reference, candidates)` so hosts and other tools can reuse the same exact-first, unique-prefix lookup without diff --git a/skills/transcript/SKILL.md b/skills/transcript/SKILL.md index 3de53d12..910b5010 100644 --- a/skills/transcript/SKILL.md +++ b/skills/transcript/SKILL.md @@ -1,7 +1,7 @@ --- name: transcript description: Retrieve the prior user and assistant conversation verbatim from the current Context Intelligence capture, or named sessions. Use when a user asks to replay, quote, review, or act on a transcript. -version: 1.1.0 +version: 1.2.0 license: MIT user-invocable: true compatibility: Amplifier with the session_transcript tool mounted @@ -41,16 +41,28 @@ Interpret `$ARGUMENTS` as follows: - **`--session ID[,ID...] -- `:** retrieve all named sessions first, then perform the intent using those transcripts. -Bare opaque ID tokens containing a hyphen, underscore, or digit also select a -session; for other non-UUID references use explicit `--session`, which accepts -unique prefixes and exact opaque IDs. +A bare token selects a session only when it actually looks like a capture ID: +eight or more hex characters (`abcdef12`, a full UUID), or the zero-padded +sub-agent form `0000000000000000-[_]`. Anything else is intent about +the current session, even when it contains a hyphen, underscore, or digit — +`chapter-2` and `summarize-day-3` are intents, not IDs. When a token could be +read either way, treat it as intent: a wrong intent is visible and recoverable, +a wrong lookup just fails. For an opaque ID that does not match those shapes, +use explicit `--session`, which accepts unique prefixes and exact opaque IDs. If natural language explicitly names a session, pass that ID or prefix to the -tool as well. Ordinary intent text such as `summarize` still targets the -current session; it is not a session ID. +tool as well. -On an ambiguous prefix, show the tool's candidates and ask for a longer prefix -or full ID. On a missing session, report that failure. Never choose a candidate, -retry against the current session, or delegate to an agent to guess an ID. +On an ambiguous prefix, show the tool's candidates and pass on the length it +reports as needed — it names the character position where the candidates +diverge, so relay that rather than asking vaguely for "a longer prefix". On a +missing session, report that failure. Never choose a candidate, retry against +the current session, or delegate to an agent to guess an ID. + +A session whose capture directory exists but has no readable `metadata.json` is +reported as `capture_unavailable`, and an ID whose only captures are derived +`_` sub-agent sessions is reported as `session_not_found` listing +those sub-agents. Neither is a prompt to substitute one of them; report what the +tool said. For a single session, keep calling `session_transcript` with its returned full `session_id` and `next_after_event_line` as the next `after_event_line`