forked from MemPalace/mempalace
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_cursor_hooks_shell.py
More file actions
774 lines (656 loc) · 30.6 KB
/
Copy pathtest_cursor_hooks_shell.py
File metadata and controls
774 lines (656 loc) · 30.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
"""Behavioral coverage for the Cursor hook shell scripts.
Mirrors ``tests/test_hooks_shell.py`` + ``tests/test_hooks_bash_compat.py``
in shape so a future contributor recognises the pattern. The three
hooks live at ``hooks/cursor/`` and source ``hooks/cursor/lib/common.sh``.
Covered contracts:
- bash 3.2 compatibility (no ``mapfile`` / ``readarray``; ``sed -n 'Np'``
used for line extraction; ``bash -n`` clean).
- Per-conversation counter increments atomically across ``stop`` calls
and emits a ``followup_message`` only on the configured interval.
- ``MEMPAL_DISABLE_HOOK=1`` and ``MEMPALACE_HOOKS_AUTO_SAVE=false`` both
short-circuit every hook to ``{}``.
- Malformed stdin dumps the payload to a bounded 0600 file and logs a
warning; the hook still exits 0 with ``{}`` so Cursor proceeds.
- ``loop_count > 0`` short-circuits the save hook (loop-prevention).
- A pending-save marker dropped by ``preCompact`` forces a save
followup on the very next ``stop`` regardless of the counter.
- ``infer_wing_from_cwd`` handles ``/``, trailing slashes, spaces, and
empty input.
- The wake hook emits ``additional_context`` referencing the inferred
wing.
- The precompact hook drops a pending-save marker and emits the
documented ``user_message`` shape.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
HOOKS_DIR = REPO_ROOT / "hooks" / "cursor"
SAVE_HOOK = HOOKS_DIR / "mempal_save_hook_cursor.sh"
PRECOMPACT_HOOK = HOOKS_DIR / "mempal_precompact_hook_cursor.sh"
WAKE_HOOK = HOOKS_DIR / "mempal_wake_hook_cursor.sh"
COMMON_LIB = HOOKS_DIR / "lib" / "common.sh"
# All three .sh scripts, parametrised together for the source-level and
# universal-behaviour tests. ids= keeps pytest output readable.
ALL_HOOKS = pytest.mark.parametrize(
"hook",
[SAVE_HOOK, PRECOMPACT_HOOK, WAKE_HOOK],
ids=["save_hook", "precompact_hook", "wake_hook"],
)
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
# ── helpers ──────────────────────────────────────────────────────────
def _run_hook(
hook: Path,
stdin: str,
home: Path,
*,
extra_env: dict | None = None,
path_prefix: list[Path] | None = None,
expected_rc: int = 0,
) -> tuple[str, str]:
"""Invoke a hook with a controlled environment and assert exit code.
Returns ``(stdout, stderr)``. Forces ``MEMPAL_PYTHON=sys.executable``
so the hook always finds a Python that can ``import json`` — without
this, GUI-launched CI on macOS could hit a missing python3 on the
inherited PATH and produce spurious failures unrelated to the hook
logic. Forces a clean ``HOME`` so the state directory is sandboxed
under the test's ``tmp_path``.
"""
env = {
"HOME": str(home),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
}
if path_prefix:
env["PATH"] = os.pathsep.join(str(p) for p in path_prefix) + os.pathsep + env["PATH"]
if extra_env:
env.update(extra_env)
p = subprocess.run(
["bash", str(hook)],
input=stdin,
capture_output=True,
text=True,
env=env,
timeout=30,
# Force a permissive umask so the hook's own ``umask 077`` inside
# parsing subshells is provably the sole reason diagnostic files
# end up mode 0600. Without this, an ambient restrictive umask
# on the CI runner would mask a regression that drops the in-hook
# ``umask`` line. Mirrors tests/test_hooks_bash_compat.py.
preexec_fn=lambda: os.umask(0o022),
)
assert p.returncode == expected_rc, (
f"{hook.name} exited {p.returncode} (expected {expected_rc}); "
f"stderr={p.stderr!r}; stdout={p.stdout!r}"
)
return p.stdout, p.stderr
def _stop_payload(
*,
conv: str = "conv-1",
loop_count: int = 0,
transcript: str = "",
) -> str:
return json.dumps(
{
"conversation_id": conv,
"loop_count": loop_count,
"status": "completed",
"model": "claude-sonnet-4-20250514",
"hook_event_name": "stop",
"transcript_path": transcript,
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _precompact_payload(*, conv: str = "conv-1", transcript: str = "") -> str:
return json.dumps(
{
"conversation_id": conv,
"hook_event_name": "preCompact",
"trigger": "auto",
"transcript_path": transcript,
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _session_start_payload(*, conv: str = "conv-1") -> str:
return json.dumps(
{
"conversation_id": conv,
"session_id": conv,
"hook_event_name": "sessionStart",
"is_background_agent": False,
"composer_mode": "agent",
"workspace_roots": ["/Users/test/sampleProj"],
}
)
def _state_dir(home: Path) -> Path:
return home / ".mempalace" / "hook_state"
def _log_text(home: Path) -> str:
log = _state_dir(home) / "cursor_hook.log"
return log.read_text() if log.exists() else ""
# ── source-level bash 3.2 compat (matches tests/test_hooks_bash_compat.py) ──
class TestBash32Compat:
@ALL_HOOKS
def test_bash_syntax_clean(self, hook):
p = subprocess.run(
["bash", "-n", str(hook)],
capture_output=True,
text=True,
)
assert p.returncode == 0, f"{hook.name} syntax error: {p.stderr}"
def test_common_lib_syntax_clean(self):
p = subprocess.run(
["bash", "-n", str(COMMON_LIB)],
capture_output=True,
text=True,
)
assert p.returncode == 0, f"common.sh syntax error: {p.stderr}"
@ALL_HOOKS
def test_no_bash4_array_builtins(self, hook):
src = "\n".join(
line for line in hook.read_text().splitlines() if not line.lstrip().startswith("#")
)
assert "mapfile" not in src, (
f"{hook.name} uses mapfile; unavailable on macOS /bin/bash 3.2 (#1440)"
)
assert "readarray" not in src, (
f"{hook.name} uses readarray; unavailable on macOS /bin/bash 3.2 (#1440)"
)
def test_common_lib_no_bash4_array_builtins(self):
src = "\n".join(
line
for line in COMMON_LIB.read_text().splitlines()
if not line.lstrip().startswith("#")
)
assert "mapfile" not in src and "readarray" not in src, (
"common.sh uses bash-4-only array builtins; would break macOS bash 3.2"
)
def test_common_lib_uses_sed_n_for_extraction(self):
# Defense: if a future edit swaps the sed-based parser for
# mapfile (the bash-4 form), this catches it at source level
# before any behavioural test runs. ``parse_cursor_stdin`` reads
# seven values (sentinel + six fields) so we expect at least
# seven ``sed -n 'Np'`` calls.
src = "\n".join(
line
for line in COMMON_LIB.read_text().splitlines()
if not line.lstrip().startswith("#")
)
assert src.count("sed -n '") >= 7, (
"common.sh must use sed -n 'Np' for POSIX-portable line extraction"
)
# ── kill switches ───────────────────────────────────────────────────
class TestKillSwitches:
@ALL_HOOKS
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
def test_disable_hook_env_short_circuits(self, hook, value, tmp_path):
out, _ = _run_hook(
hook,
_stop_payload(),
tmp_path,
extra_env={"MEMPAL_DISABLE_HOOK": value},
)
assert json.loads(out) == {}, f"MEMPAL_DISABLE_HOOK={value} must short-circuit; got {out!r}"
# No state files should be created when the kill switch fires.
state = _state_dir(tmp_path)
assert not (state / "cursor_hook.log").exists() or _log_text(tmp_path) == ""
@ALL_HOOKS
@pytest.mark.parametrize("value", ["false", "0", "no", "off"])
def test_auto_save_env_short_circuits(self, hook, value, tmp_path):
out, _ = _run_hook(
hook,
_stop_payload(),
tmp_path,
extra_env={"MEMPALACE_HOOKS_AUTO_SAVE": value},
)
assert json.loads(out) == {}, (
f"MEMPALACE_HOOKS_AUTO_SAVE={value} must short-circuit; got {out!r}"
)
@ALL_HOOKS
def test_config_file_auto_save_false_short_circuits(self, hook, tmp_path):
cfg_dir = tmp_path / ".mempalace"
cfg_dir.mkdir(parents=True)
(cfg_dir / "config.json").write_text(json.dumps({"hooks": {"auto_save": False}}))
out, _ = _run_hook(hook, _stop_payload(), tmp_path)
assert json.loads(out) == {}, (
f"config.json hooks.auto_save=false must short-circuit; got {out!r}"
)
# ── malformed stdin ─────────────────────────────────────────────────
class TestMalformedStdin:
@ALL_HOOKS
def test_malformed_input_does_not_crash(self, hook, tmp_path):
out, _ = _run_hook(hook, "not-json garbage", tmp_path)
# Must still produce parseable JSON so Cursor proceeds.
assert json.loads(out) == {}, f"hook must emit {{}} on malformed input; got {out!r}"
@ALL_HOOKS
def test_malformed_input_logs_warning_and_dumps_payload(self, hook, tmp_path):
_run_hook(hook, "not-json garbage", tmp_path)
state = _state_dir(tmp_path)
log = (state / "cursor_hook.log").read_text()
assert "WARN: input parse failed" in log, (
f"expected parse-failure warning in log; got: {log!r}"
)
dump = state / "cursor_last_input.log"
assert dump.exists()
assert "not-json garbage" in dump.read_text()
@ALL_HOOKS
def test_dump_is_mode_0600(self, hook, tmp_path):
_run_hook(hook, "not-json garbage", tmp_path)
dump = _state_dir(tmp_path) / "cursor_last_input.log"
mode = stat.S_IMODE(dump.stat().st_mode)
assert mode == 0o600, f"cursor_last_input.log mode should be 0600, got {oct(mode)}"
@ALL_HOOKS
def test_dump_cap_at_4096_bytes(self, hook, tmp_path):
_run_hook(hook, "x" * 4097, tmp_path)
dump = _state_dir(tmp_path) / "cursor_last_input.log"
assert dump.stat().st_size == 4096, (
f"cap must be exactly 4096 bytes; got {dump.stat().st_size}"
)
@ALL_HOOKS
def test_empty_stdin_does_not_dump(self, hook, tmp_path):
out, _ = _run_hook(hook, "", tmp_path)
assert json.loads(out) == {}
dump = _state_dir(tmp_path) / "cursor_last_input.log"
assert not dump.exists(), "empty stdin must not produce a dump file"
@ALL_HOOKS
def test_successful_parse_leaves_no_python_err_log(self, hook, tmp_path):
if hook == SAVE_HOOK:
payload = _stop_payload()
elif hook == PRECOMPACT_HOOK:
payload = _precompact_payload()
else:
payload = _session_start_payload()
_run_hook(hook, payload, tmp_path)
err_log = _state_dir(tmp_path) / "cursor_last_python_err.log"
assert not err_log.exists(), "successful parse must clean up cursor_last_python_err.log"
# ── save hook: counter + threshold ──────────────────────────────────
class TestSaveHookCounter:
def test_counter_increments_across_invocations(self, tmp_path):
for _ in range(3):
out, _ = _run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
assert json.loads(out) == {}, "below threshold must be a no-op"
counter_file = _state_dir(tmp_path) / "cursor_conv-A.count"
assert counter_file.read_text() == "3", (
f"counter should be 3 after 3 invocations; got {counter_file.read_text()!r}"
)
def test_counter_per_conversation_isolated(self, tmp_path):
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-B"), tmp_path)
assert (_state_dir(tmp_path) / "cursor_conv-A.count").read_text() == "2"
assert (_state_dir(tmp_path) / "cursor_conv-B.count").read_text() == "1"
def test_threshold_emits_followup_message(self, tmp_path):
# Lower the interval to keep the test fast.
env = {"MEMPAL_SAVE_INTERVAL": "3"}
for _ in range(2):
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert json.loads(out) == {}
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
response = json.loads(out)
assert "followup_message" in response, (
f"third invocation must emit a followup_message; got {response!r}"
)
msg = response["followup_message"]
# Followup must reference the real MCP tool name (regression
# guard against future typos that would silently fail). The save
# is driven by a single batch checkpoint call.
assert "mempalace_checkpoint" in msg
assert "cursor-ide" in msg, "diary entries must be tagged agent_name=cursor-ide"
def test_threshold_followup_references_inferred_wing(self, tmp_path):
env = {"MEMPAL_SAVE_INTERVAL": "1"}
# workspace_roots[0] = /Users/test/sampleProj -> wing=sampleproj
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
msg = json.loads(out)["followup_message"]
assert "sampleproj" in msg, f"followup should reference inferred wing; got {msg!r}"
def test_save_interval_zero_is_coerced_to_default(self, tmp_path):
"""Regression for gh-PR review: MEMPAL_SAVE_INTERVAL=0 would
otherwise crash bash on `$((NEXT % 0))` (division by zero).
Zero must be coerced to the default interval (15) so the hook
survives a misconfigured env var without exiting non-zero.
"""
env = {"MEMPAL_SAVE_INTERVAL": "0"}
# Three independent invocations: each must succeed (rc=0) and
# emit {} since the coerced interval (15) is never reached.
for _ in range(3):
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert json.loads(out) == {}, (
f"SAVE_INTERVAL=0 must coerce to default and pass through; got {out!r}"
)
# ── save hook: followup opt-out ─────────────────────────────────────
class TestSaveHookFollowupSilence:
"""The Cursor followup_message is ON by default (it is the
load-bearing verbatim path because Cursor's transcript is unminable),
but users can silence it. These tests lock the opt-out contract.
"""
def test_followup_on_by_default_at_threshold(self, tmp_path):
"""Sanity baseline: with no silence flag, the threshold emits a
followup. Guards against an accidental default flip."""
env = {"MEMPAL_SAVE_INTERVAL": "1"}
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert "followup_message" in json.loads(out)
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
def test_cursor_silent_suppresses_followup(self, value, tmp_path):
env = {"MEMPAL_SAVE_INTERVAL": "1", "MEMPAL_CURSOR_SILENT": value}
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert json.loads(out) == {}, (
f"MEMPAL_CURSOR_SILENT={value!r} must suppress the followup; got {out!r}"
)
@pytest.mark.parametrize("value", ["false", "0", "no", "off"])
def test_verbose_false_suppresses_followup(self, value, tmp_path):
env = {"MEMPAL_SAVE_INTERVAL": "1", "MEMPAL_VERBOSE": value}
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
assert json.loads(out) == {}, (
f"MEMPAL_VERBOSE={value!r} must suppress the followup; got {out!r}"
)
def test_silenced_followup_still_increments_counter(self, tmp_path):
"""Silence must not disable bookkeeping — the counter still
advances so cadence is preserved if the user re-enables."""
env = {"MEMPAL_SAVE_INTERVAL": "5", "MEMPAL_CURSOR_SILENT": "1"}
for _ in range(2):
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-S"), tmp_path, extra_env=env)
counter = _state_dir(tmp_path) / "cursor_conv-S.count"
assert counter.exists() and counter.read_text().strip() == "2", (
"silenced followup must still maintain the per-conversation counter"
)
def test_silenced_pending_marker_emits_empty(self, tmp_path):
"""A consumed pending marker normally forces a followup; under
silence it must emit {} but still clear the marker."""
env = {"MEMPAL_CURSOR_SILENT": "1"}
pending = _state_dir(tmp_path) / "cursor_conv-P.pending"
pending.parent.mkdir(parents=True, exist_ok=True)
pending.touch()
out, _ = _run_hook(SAVE_HOOK, _stop_payload(conv="conv-P"), tmp_path, extra_env=env)
assert json.loads(out) == {}
assert not pending.exists(), "pending marker must be consumed even when silenced"
# ── save hook: loop-prevention ──────────────────────────────────────
class TestSaveHookLoopPrevention:
def test_loop_count_gt_zero_short_circuits(self, tmp_path):
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(loop_count=1),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert json.loads(out) == {}, (
"loop_count > 0 must short-circuit even at the trigger interval"
)
# No counter file should be written in the short-circuit path.
assert not (_state_dir(tmp_path) / "cursor_conv-1.count").exists()
def test_loop_count_zero_does_not_short_circuit(self, tmp_path):
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(loop_count=0),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert "followup_message" in json.loads(out)
# ── save hook: pending-save marker from preCompact ──────────────────
class TestPendingSaveMarker:
def test_pending_marker_forces_followup_regardless_of_counter(self, tmp_path):
state = _state_dir(tmp_path)
state.mkdir(parents=True, exist_ok=True)
# Drop the marker as if precompact had run.
(state / "cursor_conv-1.pending").write_text("")
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(),
tmp_path,
# SAVE_INTERVAL=1000 ensures the normal counter path would
# not trigger; the marker is the only reason a followup
# gets emitted.
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
)
response = json.loads(out)
assert "followup_message" in response, (
"pending marker must force a followup even far below threshold"
)
# Marker must be consumed on read.
assert not (state / "cursor_conv-1.pending").exists(), (
"pending marker must be deleted after consumption"
)
def test_pending_marker_is_per_conversation(self, tmp_path):
state = _state_dir(tmp_path)
state.mkdir(parents=True, exist_ok=True)
(state / "cursor_conv-OTHER.pending").write_text("")
out, _ = _run_hook(
SAVE_HOOK,
_stop_payload(conv="conv-1"),
tmp_path,
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
)
# conv-1 has no marker -> counter path -> no trigger -> {}.
assert json.loads(out) == {}
# conv-OTHER marker must NOT be consumed by conv-1's invocation.
assert (state / "cursor_conv-OTHER.pending").exists()
# ── preCompact hook ─────────────────────────────────────────────────
class TestPreCompactHook:
def test_emits_user_message(self, tmp_path):
out, _ = _run_hook(PRECOMPACT_HOOK, _precompact_payload(), tmp_path)
response = json.loads(out)
# Cursor's preCompact only accepts user_message; never
# followup_message or decision.
assert "user_message" in response, f"expected user_message; got {response!r}"
assert "followup_message" not in response
assert "decision" not in response
def test_drops_pending_marker(self, tmp_path):
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-X"), tmp_path)
marker = _state_dir(tmp_path) / "cursor_conv-X.pending"
assert marker.exists(), "preCompact must drop a pending-save marker"
def test_logs_trigger(self, tmp_path):
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-Y"), tmp_path)
log = _log_text(tmp_path)
assert "event=preCompact" in log
assert "conv=conv-Y" in log
assert "trigger=auto" in log
# ── wake (sessionStart) hook ───────────────────────────────────────
class TestWakeHook:
def test_emits_additional_context(self, tmp_path):
out, _ = _run_hook(WAKE_HOOK, _session_start_payload(), tmp_path)
response = json.loads(out)
assert "additional_context" in response, (
f"sessionStart must emit additional_context; got {response!r}"
)
ctx = response["additional_context"]
# Must reference the inferred wing AND the real MCP tools.
assert "sampleproj" in ctx, f"context should reference inferred wing; got {ctx!r}"
assert "mempalace_search" in ctx
assert "mempalace_diary_read" in ctx
assert "cursor-ide" in ctx
def test_falls_back_to_env_when_workspace_roots_missing(self, tmp_path):
# Cursor always provides workspace_roots, but the env-var
# fallback path needs coverage so a future Cursor schema
# change cannot silently break the wake hook.
payload = json.dumps(
{
"conversation_id": "conv-Z",
"session_id": "conv-Z",
"hook_event_name": "sessionStart",
"is_background_agent": False,
"composer_mode": "agent",
}
)
out, _ = _run_hook(
WAKE_HOOK,
payload,
tmp_path,
extra_env={"CURSOR_PROJECT_DIR": "/Users/test/envFallback"},
)
ctx = json.loads(out)["additional_context"]
assert "envfallback" in ctx, (
f"env-var fallback workspace should drive wing inference; got {ctx!r}"
)
# ── infer_wing_from_cwd via direct function call ────────────────────
def _call_infer_wing(arg: str) -> str:
"""Source common.sh in a bash subshell and invoke mempal_infer_wing.
Returns the function's stdout. Uses bash -c so we never have to
pollute the test's own shell environment with the common.sh state
(which mkdir's directories and resolves Python paths).
"""
script = f'. "{COMMON_LIB}" >/dev/null 2>&1; mempal_infer_wing "$1"'
# The argument is passed as a positional so it survives any shell
# quirks around spaces/empty values exactly as the production hook
# would see them.
p = subprocess.run(
["bash", "-c", script, "_test", arg],
capture_output=True,
text=True,
env={
"HOME": "/tmp",
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
},
timeout=10,
)
assert p.returncode == 0, f"infer_wing call failed: {p.stderr!r}"
return p.stdout
class TestInferWing:
def test_basename_of_normal_path(self):
assert _call_infer_wing("/Users/me/myproject") == "myproject"
def test_strips_trailing_slash(self):
assert _call_infer_wing("/Users/me/myproject/") == "myproject"
def test_root_path_falls_back(self):
assert _call_infer_wing("/") == "root"
def test_empty_input_falls_back(self):
assert _call_infer_wing("") == "cursor_session"
def test_spaces_collapsed_to_underscore(self):
assert _call_infer_wing("/Users/me/my project") == "my_project"
def test_lowercases_uppercase_basename(self):
# Cursor on macOS often hands us /Users/<user>/Projects/MyApp.
# The wing scoping in MemPalace's MCP tools is case-sensitive,
# so the wake hook and save hook must produce identical wings
# for the same workspace — lowercasing is the simplest
# contract.
assert _call_infer_wing("/Users/me/MyApp") == "myapp"
def test_windows_style_path(self):
# Cursor on Windows passes C:\path\to\Project as workspace_root.
# The hook scripts are POSIX-only (we skip them on Windows) but
# WSL users may still hit a backslash-bearing path via the
# CURSOR_PROJECT_DIR env var when Cursor is launched from
# PowerShell.
assert _call_infer_wing(r"C:\Users\me\MyProj") == "myproj"
# ── state-file TTL + GC ─────────────────────────────────────────────
def _run_common_snippet(snippet: str, home: Path, *, extra_env: dict | None = None) -> str:
"""Source common.sh and run a bash snippet against a sandboxed HOME.
Returns stdout. Used to exercise mempal_state_ttl_days /
mempal_gc_stale_state directly without going through a full hook.
"""
script = f'. "{COMMON_LIB}" >/dev/null 2>&1; {snippet}'
env = {
"HOME": str(home),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"MEMPAL_PYTHON": sys.executable,
}
if extra_env:
env.update(extra_env)
p = subprocess.run(
["bash", "-c", script, "_test"],
capture_output=True,
text=True,
env=env,
timeout=15,
)
assert p.returncode == 0, f"snippet failed: {p.stderr!r}"
return p.stdout
def _age_file(path: Path, days: int) -> None:
old = time.time() - days * 86400
os.utime(path, (old, old))
class TestStateTtlDays:
@pytest.mark.parametrize(
"value,expected",
[
("", "30"),
("abc", "30"),
("45", "45"),
("08", "8"),
("007", "7"),
("0", "0"),
],
)
def test_ttl_validation_and_octal_strip(self, value, expected, tmp_path):
out = _run_common_snippet(
"mempal_state_ttl_days",
tmp_path,
extra_env={"MEMPAL_STATE_TTL_DAYS": value} if value != "" else {},
)
assert out.strip() == expected, (
f"MEMPAL_STATE_TTL_DAYS={value!r} should resolve to {expected!r}; got {out.strip()!r}"
)
def test_ttl_default_when_unset(self, tmp_path):
assert _run_common_snippet("mempal_state_ttl_days", tmp_path).strip() == "30"
class TestStateGc:
def test_removes_stale_count_and_pending(self, tmp_path):
sd = _state_dir(tmp_path)
sd.mkdir(parents=True, exist_ok=True)
stale_count = sd / "cursor_old.count"
stale_pending = sd / "cursor_old.pending"
fresh_count = sd / "cursor_new.count"
for f in (stale_count, stale_pending, fresh_count):
f.write_text("1")
_age_file(stale_count, 40)
_age_file(stale_pending, 40)
_run_common_snippet("mempal_gc_stale_state", tmp_path)
assert not stale_count.exists(), "stale .count older than TTL must be swept"
assert not stale_pending.exists(), "stale .pending older than TTL must be swept"
assert fresh_count.exists(), "recent state must be preserved"
def test_preserves_shared_logs_and_other_editor_state(self, tmp_path):
sd = _state_dir(tmp_path)
sd.mkdir(parents=True, exist_ok=True)
# Shared logs + another editor's state, all aged well past the TTL.
keep = [
sd / "cursor_hook.log",
sd / "cursor_last_input.log",
sd / "cursor_last_python_err.log",
sd / "antigravity_save_count_xyz",
sd / "hook.log",
]
for f in keep:
f.write_text("x")
_age_file(f, 99)
_run_common_snippet("mempal_gc_stale_state", tmp_path)
for f in keep:
assert f.exists(), f"GC must never touch {f.name}"
def test_creates_sweep_marker(self, tmp_path):
_run_common_snippet("mempal_gc_stale_state", tmp_path)
assert (_state_dir(tmp_path) / "cursor_last_sweep").exists()
def test_throttled_within_24h(self, tmp_path):
sd = _state_dir(tmp_path)
sd.mkdir(parents=True, exist_ok=True)
# A fresh sweep marker must suppress a second sweep, so a stale
# file created afterwards survives until the throttle expires.
(sd / "cursor_last_sweep").write_text("")
stale = sd / "cursor_old.count"
stale.write_text("1")
_age_file(stale, 40)
_run_common_snippet("mempal_gc_stale_state", tmp_path)
assert stale.exists(), "GC must be throttled when last_sweep is recent"
def test_gc_gated_by_kill_switch(self, tmp_path):
"""A disabled hook must not sweep (or even create the marker)."""
sd = _state_dir(tmp_path)
sd.mkdir(parents=True, exist_ok=True)
stale = sd / "cursor_zombie.count"
stale.write_text("1")
_age_file(stale, 40)
_run_hook(
SAVE_HOOK,
_stop_payload(),
tmp_path,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert stale.exists(), "disabled hook must not GC state"
assert not (sd / "cursor_last_sweep").exists(), (
"disabled hook must not even create the sweep marker"
)
# ── logging discipline ─────────────────────────────────────────────
class TestLogging:
def test_log_uses_iso8601_utc_timestamps(self, tmp_path):
_run_hook(SAVE_HOOK, _stop_payload(), tmp_path)
log = _log_text(tmp_path)
# ISO 8601 with 'Z' suffix means UTC, locale-independent.
# Regression guard against switching back to %H:%M:%S which
# loses both the date and the timezone.
assert "T" in log and "Z]" in log, f"log timestamps must be ISO 8601 UTC; got: {log!r}"