Skip to content

Commit 51a15b3

Browse files
feat(ci): smoke gate probes memory recall round-trip + fix recall --json stdout pollution (#1219)
feat(ci): smoke gate probes memory recall round-trip + fix --json stdout pollution Extends the required default-install-smoke gate beyond CLI boot to the personal-memory capture -> recall round-trip, and fixes the product bug the probe surfaced. Why: 9.3.0 shipped with `attune memory recall` broken (capture ok, recall printed "No results found." and exited 0) while every unit test was green - the tests mocked the RAG layer that was broken. The gate that existed then only booted the CLI, so the class sailed through. The probe reproduces the clean-venv + fake-HOME audit that caught the bug, against the shipped wheel, asserting on recall CONTENT (never the exit code, which is 0 even for zero hits). Product fix found by dogfooding the probe: the RAG layer logs to stdout (structlog default PrintLogger), so `attune memory recall --json` emitted a rag.run log line ahead of the JSON - unparseable for machine consumers (`... --json | jq` failed). cmd_memory_recall now captures query-time stdout noise and forwards it to stderr, keeping stdout pure result output in both modes. Regression test added (test_json_output_survives_stdout_log_noise). Receipt: full script run against a locally built 9.4.0 wheel - bare install, CLI boots, capture -> recall returns the captured content, log noise on stderr, stdout parses as pure JSON. 30/30 memory-command tests green; workflow-yaml guard suite green (261 passed). Job id/name unchanged (default-install-smoke is a required check). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bfcc440 commit 51a15b3

4 files changed

Lines changed: 110 additions & 7 deletions

File tree

.github/workflows/tests.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,11 @@ jobs:
533533
# transitively imports an extras-only dependency (e.g. fastapi from the
534534
# [ops] extra) and crashes on startup — invisible to the rest of the
535535
# suite, which always installs the extras. 8.5.0 shipped exactly this
536-
# (attune --help -> ModuleNotFoundError: fastapi). Self-contained (it
536+
# (attune --help -> ModuleNotFoundError: fastapi). Also probes the
537+
# personal-memory capture -> recall round-trip under a fake HOME,
538+
# asserting on recall CONTENT: 9.3.0 shipped with recall broken
539+
# (exit 0, "No results found.") while unit tests stayed green because
540+
# they mocked the broken RAG layer. Self-contained (it
537541
# builds its own wheel) so it never skips on an upstream failure — a
538542
# skipped required check would block every merge.
539543
name: default-install-smoke

scripts/smoke_default_install.sh

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
# This gate reproduces the real default-install environment and boots the
1616
# CLI, so that class can never ship again.
1717
#
18+
# It also probes the personal-memory capture -> recall round-trip against
19+
# the shipped wheel. 9.3.0 shipped with `attune memory recall` broken
20+
# (capture succeeded; recall printed "No results found." and exited 0)
21+
# while every unit test was green — the tests mocked the RAG layer that
22+
# was broken. The probe asserts on recall CONTENT under a fake HOME,
23+
# never on the exit code, so that class can't ship again either.
24+
#
1825
# Usage:
1926
# scripts/smoke_default_install.sh [path/to/attune_ai-*.whl]
2027
#
@@ -61,4 +68,48 @@ ATTUNE="$WORK/venv/bin/attune"
6168
"$ATTUNE" --help >/dev/null
6269
"$ATTUNE" version
6370

64-
echo "PASS: default-install CLI boots without extras"
71+
echo "== smoke: personal-memory capture -> recall must round-trip =="
72+
# The 9.3.0 regression class: recall exits 0 even with zero hits, so a
73+
# boot-only smoke can't see it. Probe under a fake HOME (isolated global
74+
# memory root, exactly the clean-venv + fake-HOME audit that caught the
75+
# original bug) and assert on the returned content. ANTHROPIC_API_KEY is
76+
# set EMPTY (not unset) so dotenv cannot inject a real key; the whole
77+
# round-trip is keyless (polish degrades gracefully without attune-author).
78+
PROBE_HOME="$WORK/home"
79+
mkdir -p "$PROBE_HOME"
80+
SENTINEL="smoke sentinel: the quarterly fox prefers decaf espresso"
81+
82+
(
83+
cd "$WORK"
84+
HOME="$PROBE_HOME" ANTHROPIC_API_KEY="" \
85+
"$ATTUNE" memory capture smoke-recall-probe "$SENTINEL" --kind reference
86+
)
87+
CAPTURED="$PROBE_HOME/.attune/memory/smoke-recall-probe/reference.md"
88+
if [[ ! -f "$CAPTURED" ]]; then
89+
echo "FAIL: capture wrote nothing at $CAPTURED"
90+
exit 1
91+
fi
92+
93+
RECALL_JSON="$(
94+
cd "$WORK"
95+
HOME="$PROBE_HOME" ANTHROPIC_API_KEY="" \
96+
"$ATTUNE" memory recall "quarterly fox decaf espresso" --json
97+
)"
98+
# NOTE: `python - <<HEREDOC` would consume stdin for the script itself,
99+
# leaving json.load(sys.stdin) an empty stream — pipe into `-c` instead.
100+
printf '%s' "$RECALL_JSON" | "$VPY" -c '
101+
import json
102+
import sys
103+
104+
hits = json.load(sys.stdin)
105+
if not hits:
106+
sys.exit(
107+
"FAIL: recall returned zero hits for freshly captured content "
108+
"(the 9.3.0 broken-round-trip class - exit code alone would not catch this)"
109+
)
110+
if not any("smoke-recall-probe" in hit.get("path", "") for hit in hits):
111+
sys.exit("FAIL: recall hits do not include the captured topic: %r" % hits)
112+
print("confirmed: recall round-trips (%d hit(s), top: %s)" % (len(hits), hits[0]["path"]))
113+
'
114+
115+
echo "PASS: default-install CLI boots and personal memory round-trips without extras"

src/attune/cli_commands/memory_commands.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,17 +154,29 @@ def cmd_memory_recall(args: Namespace) -> int:
154154
0 on success, 1 on failure.
155155
156156
"""
157+
import contextlib
158+
import io
157159
import json as json_mod
160+
import sys
158161

159162
from attune.memory.personal import PersonalMemory
160163

161164
try:
162165
pm = PersonalMemory(project_root=None)
163-
hits = pm.query(
164-
args.query,
165-
k=getattr(args, "k", 3),
166-
kind_filter=getattr(args, "kind_filter", None),
167-
)
166+
# The RAG layer logs to stdout (structlog's default PrintLogger),
167+
# which pollutes `--json` output and breaks machine consumers
168+
# (`attune memory recall ... --json | jq` fails on the log line).
169+
# Capture anything the query writes to stdout and forward it to
170+
# stderr, keeping stdout pure result output in both modes.
171+
log_noise = io.StringIO()
172+
with contextlib.redirect_stdout(log_noise):
173+
hits = pm.query(
174+
args.query,
175+
k=getattr(args, "k", 3),
176+
kind_filter=getattr(args, "kind_filter", None),
177+
)
178+
if log_noise.getvalue():
179+
print(log_noise.getvalue(), file=sys.stderr, end="")
168180
if getattr(args, "json", False):
169181
print(json_mod.dumps(hits, indent=2))
170182
return 0

tests/unit/test_cli_memory_commands.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,42 @@ def test_json_output(self, MockPM, capsys) -> None:
411411
parsed = json_mod.loads(out)
412412
assert parsed[0]["score"] == 0.9
413413

414+
@patch(_PERSONAL_MEM)
415+
def test_json_output_survives_stdout_log_noise(self, MockPM, capsys) -> None:
416+
"""--json stdout stays parseable when the query layer logs to stdout.
417+
418+
Regression guard: the RAG layer logs via structlog's default
419+
PrintLogger, which writes to stdout. That polluted `--json` output
420+
(a `rag.run` info line ahead of the JSON), breaking machine
421+
consumers like `attune memory recall ... --json | jq`. The handler
422+
must forward that noise to stderr and keep stdout pure JSON.
423+
"""
424+
import json as json_mod
425+
426+
from attune.cli_commands.memory_commands import cmd_memory_recall
427+
428+
hits = [{"path": "auth-design/decision.md", "score": 0.9}]
429+
430+
def noisy_query(*_args, **_kwargs):
431+
print("2026-07-02 [info] rag.run retriever=KeywordRetriever")
432+
return hits
433+
434+
MockPM.return_value.query.side_effect = noisy_query
435+
436+
args = MagicMock()
437+
args.query = "auth"
438+
args.k = 3
439+
args.kind_filter = None
440+
args.json = True
441+
442+
result = cmd_memory_recall(args)
443+
444+
assert result == 0
445+
captured = capsys.readouterr()
446+
parsed = json_mod.loads(captured.out) # raises if stdout is polluted
447+
assert parsed[0]["score"] == 0.9
448+
assert "rag.run" in captured.err # noise forwarded, not swallowed
449+
414450

415451
class TestCmdMemoryTopics:
416452
"""Tests for cmd_memory_topics."""

0 commit comments

Comments
 (0)