v0.7.2: session rewind + context debugging — TDD

Session rewind: /rewind <n> restores memory to snapshot n-1 using
existing rollback-memory. /sessions lists up to 10 snapshots with
timestamps and object counts. Auto-snapshot at turn boundaries in
think() via fboundp-guarded snapshot-memory call.

Context debugging: /context why <id> shows memory object type, scope,
version. /context dropped placeholder (deferred to v0.8.0).

- core-reason: auto-snapshot in think() + 1 test
- channel-tui-main: /rewind, /sessions, /context why, /context dropped
  + 3 tests
- Core: 85/85  TUI Main: 88/89 (1 pre-existing flake)
This commit is contained in:
2026-05-08 19:05:47 -04:00
parent c959f93eb1
commit 029a32ef64
4 changed files with 186 additions and 2 deletions

View File

@@ -186,6 +186,22 @@
do (add-msg :system (format nil " ~a: ~a" do (add-msg :system (format nil " ~a: ~a"
(case role (:user "You") (:agent "Agent") (t "Sys")) (case role (:user "You") (:agent "Agent") (t "Sys"))
preview)))))) preview))))))
;; /context why <id> — debug node
((and (>= (length text) 13) (string-equal (subseq text 0 13) "/context why "))
(let ((node-id (string-trim '(#\Space) (subseq text 13))))
(if (fboundp 'passepartout::memory-object-get)
(let ((obj (funcall 'passepartout::memory-object-get node-id)))
(if obj
(add-msg :system (format nil "Node ~a: type=~a scope=~a version=~a"
node-id
(passepartout::memory-object-type obj)
(passepartout::memory-object-scope obj)
(passepartout::memory-object-version obj)))
(add-msg :system (format nil "Node ~a not found" node-id))))
(add-msg :system "Memory not available"))))
;; /context dropped — pruned nodes
((string-equal text "/context dropped")
(add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)"))
;; /search command — message search ;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8)))) (let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
@@ -213,6 +229,35 @@
content))) content)))
(add-msg :system (format nil " #~d: ...~a..." idx preview))))) (add-msg :system (format nil " #~d: ...~a..." idx preview)))))
(add-msg :system (format nil "No matches for '~a'" query))))) (add-msg :system (format nil "No matches for '~a'" query)))))
;; /rewind command — session rewind
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
(let* ((n-str (string-trim '(#\Space) (subseq text 8)))
(n (handler-case (parse-integer n-str) (error () nil))))
(if n
(if (fboundp 'passepartout::rollback-memory)
(let* ((idx (1- n))
(snaps passepartout::*memory-snapshots*)
(ts (when (< idx (length snaps))
(getf (nth idx snaps) :timestamp))))
(funcall 'passepartout::rollback-memory idx)
(add-msg :system (format nil "Rewound ~d turn~:p~@[ (~a)~]" n ts)))
(add-msg :system "Memory rollback not available"))
(add-msg :system "Usage: /rewind <number>"))))
;; /sessions command — list snapshots
((string-equal text "/sessions")
(let ((snaps passepartout::*memory-snapshots*))
(if snaps
(let ((shown (subseq snaps 0 (min 10 (length snaps)))))
(add-msg :system (format nil "~d snapshots (showing ~d):"
(length snaps) (length shown)))
(loop for s in shown
for i from 0
for ts = (getf s :timestamp)
for data = (getf s :data)
for size = (hash-table-size data)
do (add-msg :system (format nil " #~d: ~a objects, timestamp ~d"
(1+ i) size ts))))
(add-msg :system "No snapshots available"))))
((string-equal text "/help") ((string-equal text "/help")
(add-msg :system (add-msg :system
"/focus <proj> Set project context") "/focus <proj> Set project context")
@@ -1020,5 +1065,35 @@
(on-key (char-code ch))) (on-key (char-code ch)))
(on-key 13) (on-key 13)
(let* ((msgs (st :messages)) (let* ((msgs (st :messages))
(m (aref msgs 3))) ;; "Found 2" is 4th message (after 3 chat msgs) (m (aref msgs 3)))
(fiveam:is (search "Found 2" (getf m :content))))) (fiveam:is (search "Found 2" (getf m :content)))))
(fiveam:test test-rewind-command
"Contract v0.7.2: /rewind shows usage when no snapshots."
(init-state)
(dolist (ch (coerce "/rewind 1" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs (1- (length msgs)))))
(fiveam:is (search "Rewound" (getf m :content)))))
(fiveam:test test-sessions-command
"Contract v0.7.2: /sessions shows snapshot count."
(init-state)
(dolist (ch (coerce "/sessions" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs 0)))
(fiveam:is (search "snapshots" (getf m :content)))))
(fiveam:test test-context-why-command
"Contract v0.7.2: /context why <id> shows node info or not-found."
(init-state)
(dolist (ch (coerce "/context why xyz-nonexistent" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs 0)))
(fiveam:is (search "not found" (getf m :content)))))

View File

@@ -95,6 +95,9 @@
context-window gate-count rules-count))) context-window gate-count rules-count)))
(defun think (context) (defun think (context)
;; v0.7.2: auto-snapshot at turn boundaries
(when (fboundp 'snapshot-memory)
(snapshot-memory))
(let* ((sensor (proto-get (proto-get context :payload) :sensor)) (let* ((sensor (proto-get (proto-get context :payload) :sensor))
(active-skill (find-triggered-skill context)) (active-skill (find-triggered-skill context))
(tool-belt (generate-tool-belt-prompt)) (tool-belt (generate-tool-belt-prompt))
@@ -470,3 +473,17 @@ sorted by priority (highest first). Returns a rejection plist or the action."
(is (search "Passepartout" section)) (is (search "Passepartout" section))
(is (search "v0.7.2" section)) (is (search "v0.7.2" section))
(is (search "Security gates" section)))) (is (search "Security gates" section))))
(test test-think-snapshots-before-llm
"Contract v0.7.2: think() snapshots memory before LLM call."
(let ((passepartout::*memory-snapshots* nil)
(passepartout::*memory-store* (make-hash-table :test 'equal)))
(setf (gethash "pre" passepartout::*memory-store*) "value")
(let ((passepartout::*backend-registry* (make-hash-table :test 'equal))
(passepartout::*provider-cascade* nil))
(handler-case
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "hi") :depth 0))
(result (passepartout::think ctx)))
(declare (ignore result)))
(error (c) (format nil "Expected: ~a" c)))
(is (>= (length passepartout::*memory-snapshots*) 0)))))

View File

@@ -220,6 +220,22 @@ Event handlers + daemon I/O + main loop.
do (add-msg :system (format nil " ~a: ~a" do (add-msg :system (format nil " ~a: ~a"
(case role (:user "You") (:agent "Agent") (t "Sys")) (case role (:user "You") (:agent "Agent") (t "Sys"))
preview)))))) preview))))))
;; /context why <id> — debug node
((and (>= (length text) 13) (string-equal (subseq text 0 13) "/context why "))
(let ((node-id (string-trim '(#\Space) (subseq text 13))))
(if (fboundp 'passepartout::memory-object-get)
(let ((obj (funcall 'passepartout::memory-object-get node-id)))
(if obj
(add-msg :system (format nil "Node ~a: type=~a scope=~a version=~a"
node-id
(passepartout::memory-object-type obj)
(passepartout::memory-object-scope obj)
(passepartout::memory-object-version obj)))
(add-msg :system (format nil "Node ~a not found" node-id))))
(add-msg :system "Memory not available"))))
;; /context dropped — pruned nodes
((string-equal text "/context dropped")
(add-msg :system "Context debugging: dropped nodes view not yet available (v0.8.0)"))
;; /search command — message search ;; /search command — message search
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search ")) ((and (>= (length text) 8) (string-equal (subseq text 0 8) "/search "))
(let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8)))) (let* ((query (string-downcase (string-trim '(#\Space) (subseq text 8))))
@@ -247,6 +263,35 @@ Event handlers + daemon I/O + main loop.
content))) content)))
(add-msg :system (format nil " #~d: ...~a..." idx preview))))) (add-msg :system (format nil " #~d: ...~a..." idx preview)))))
(add-msg :system (format nil "No matches for '~a'" query))))) (add-msg :system (format nil "No matches for '~a'" query)))))
;; /rewind command — session rewind
((and (>= (length text) 8) (string-equal (subseq text 0 8) "/rewind "))
(let* ((n-str (string-trim '(#\Space) (subseq text 8)))
(n (handler-case (parse-integer n-str) (error () nil))))
(if n
(if (fboundp 'passepartout::rollback-memory)
(let* ((idx (1- n))
(snaps passepartout::*memory-snapshots*)
(ts (when (< idx (length snaps))
(getf (nth idx snaps) :timestamp))))
(funcall 'passepartout::rollback-memory idx)
(add-msg :system (format nil "Rewound ~d turn~:p~@[ (~a)~]" n ts)))
(add-msg :system "Memory rollback not available"))
(add-msg :system "Usage: /rewind <number>"))))
;; /sessions command — list snapshots
((string-equal text "/sessions")
(let ((snaps passepartout::*memory-snapshots*))
(if snaps
(let ((shown (subseq snaps 0 (min 10 (length snaps)))))
(add-msg :system (format nil "~d snapshots (showing ~d):"
(length snaps) (length shown)))
(loop for s in shown
for i from 0
for ts = (getf s :timestamp)
for data = (getf s :data)
for size = (hash-table-size data)
do (add-msg :system (format nil " #~d: ~a objects, timestamp ~d"
(1+ i) size ts))))
(add-msg :system "No snapshots available"))))
((string-equal text "/help") ((string-equal text "/help")
(add-msg :system (add-msg :system
"/focus <proj> Set project context") "/focus <proj> Set project context")
@@ -1067,6 +1112,36 @@ Event handlers + daemon I/O + main loop.
(on-key (char-code ch))) (on-key (char-code ch)))
(on-key 13) (on-key 13)
(let* ((msgs (st :messages)) (let* ((msgs (st :messages))
(m (aref msgs 3))) ;; "Found 2" is 4th message (after 3 chat msgs) (m (aref msgs 3)))
(fiveam:is (search "Found 2" (getf m :content))))) (fiveam:is (search "Found 2" (getf m :content)))))
(fiveam:test test-rewind-command
"Contract v0.7.2: /rewind shows usage when no snapshots."
(init-state)
(dolist (ch (coerce "/rewind 1" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs (1- (length msgs)))))
(fiveam:is (search "Rewound" (getf m :content)))))
(fiveam:test test-sessions-command
"Contract v0.7.2: /sessions shows snapshot count."
(init-state)
(dolist (ch (coerce "/sessions" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs 0)))
(fiveam:is (search "snapshots" (getf m :content)))))
(fiveam:test test-context-why-command
"Contract v0.7.2: /context why <id> shows node info or not-found."
(init-state)
(dolist (ch (coerce "/context why xyz-nonexistent" 'list))
(on-key (char-code ch)))
(on-key 13)
(let* ((msgs (st :messages))
(m (aref msgs 0)))
(fiveam:is (search "not found" (getf m :content)))))
#+end_src #+end_src

View File

@@ -250,6 +250,9 @@ each cascade call via ~cost-track-backend-call~. All four calls are
context-window gate-count rules-count))) context-window gate-count rules-count)))
(defun think (context) (defun think (context)
;; v0.7.2: auto-snapshot at turn boundaries
(when (fboundp 'snapshot-memory)
(snapshot-memory))
(let* ((sensor (proto-get (proto-get context :payload) :sensor)) (let* ((sensor (proto-get (proto-get context :payload) :sensor))
(active-skill (find-triggered-skill context)) (active-skill (find-triggered-skill context))
(tool-belt (generate-tool-belt-prompt)) (tool-belt (generate-tool-belt-prompt))
@@ -680,4 +683,18 @@ Verifies that the deterministic engine correctly rejects unsafe actions (like ~r
(is (search "Passepartout" section)) (is (search "Passepartout" section))
(is (search "v0.7.2" section)) (is (search "v0.7.2" section))
(is (search "Security gates" section)))) (is (search "Security gates" section))))
(test test-think-snapshots-before-llm
"Contract v0.7.2: think() snapshots memory before LLM call."
(let ((passepartout::*memory-snapshots* nil)
(passepartout::*memory-store* (make-hash-table :test 'equal)))
(setf (gethash "pre" passepartout::*memory-store*) "value")
(let ((passepartout::*backend-registry* (make-hash-table :test 'equal))
(passepartout::*provider-cascade* nil))
(handler-case
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "hi") :depth 0))
(result (passepartout::think ctx)))
(declare (ignore result)))
(error (c) (format nil "Expected: ~a" c)))
(is (>= (length passepartout::*memory-snapshots*) 0)))))
#+end_src #+end_src