feat: persist and display model thinking traces (opt-in)#655
Conversation
d308861 to
b57ed3d
Compare
|
Thanks for the PR. I reviewed this against current The feature direction is useful, and keeping it opt-in/default-off is the right starting point. However, this introduces durable storage and UI exposure for sensitive thinking/reasoning traces, so we need a stronger implementation boundary before merging. Requested changes:
Validation I ran: UX/performance assessment:
Once the items above are addressed, this should be much safer to merge. |
1330f0e to
717e13f
Compare
|
Rebased onto v2.6.2 (clean, zero conflicts). All changes from v2 remain — this is just a rebase to stay current with main. Quick status recap since the v2 push:
Additional improvements beyond the 5 requested items are documented in the PR description under "Additional self-found improvements" (X1–X6, I1, I6, Q3–Q21). Ready for re-review whenever you have time. |
Adds an opt-in feature that persists agent reasoning traces (Anthropic
extended thinking blocks) to the messages database so they survive page
reloads and can be re-opened from the chat timeline via a collapsible
pill in both the classic and visual UIs.
Default: OFF. Enable with PICLAW_WEB_PERSIST_THINKING=1. See
docs/thinking-persistence.md for full configuration, privacy, cleanup,
and operational guidance.
- Per-turn thinking text into a new `thinking_content` table keyed by
the parent message rowid (TEXT-stringified)
- A `thinking_ref` content block on the terminal bot message that
carries metrics (lines, duration_ms) so the timeline can render the
pill without a fetch
- The text itself is lazy-loaded via GET /agent/thinking on first expand
- 100 KB per-turn cap (UTF-16 surrogate-safe truncation), configurable
via PICLAW_WEB_PERSIST_THINKING_MAX_CHARS
1. **No content-based row rediscovery.** `storeAgentTurn` now returns
the persisted message rowid (number | null) instead of a boolean.
The previous content-LIKE-on-thinking_ref rediscovery query is gone
— replaced by direct rowid plumbing via an onMessageStored callback
that also runs BEFORE the SSE broadcast (closes a fast-click 404
race). Bonus performance win: eliminated a per-turn O(N) full table
scan of messages.
2. **Endpoint validation against the message table.** GET /agent/thinking
now requires both `message_id` AND `chat_jid`. A single JOIN query
verifies the message exists in that chat, is a bot reply
(is_bot_message=1), and carries an actual `thinking_ref` content
block (via `json_each` + `json_extract`, not substring LIKE — the
LIKE was a confused-deputy primitive). Returns 404 uniformly for any
failure (no enumeration oracle). 16 unit tests cover the truth table.
3. **Cleanup story for message deletion.** Every code path that deletes
from `messages` now also deletes the matching `thinking_content`
rows, with the pair wrapped in transactions to prevent rowid-reuse
ghosts (SQLite reuses rowids on composite-PK tables when the highest
rowid is deleted). Eight paths covered:
- `deleteMessageByRowId` (UI single delete) — atomic
- `deleteThreadByRowId` (UI thread delete) — atomic
- `rollbackChatRunWithError` (cursor rollback on error)
- `rollbackInflightRun` (crash recovery rollback)
- `permanentDeleteArchivedBranch` (branch wipe)
- `reapDreamArtifacts` × 2 (stale dream sweep)
- `cleanupDreamChat` (specific dream cleanup)
No FK CASCADE is possible because thinking_content.message_id is TEXT
and messages has a composite PK; manual cleanup matches the existing
message_media pattern. Also adds `runtime/scripts/purge-thinking-content.ts`
for retention-style bulk purges (--all, --older-than-days, --chat-jid)
with safe dry-run-by-default behavior.
4. **Privacy and storage docs.** New docs/thinking-persistence.md covers:
- Config knobs and defaults
- What is stored (schema, indexes, content blocks)
- Summarized vs full thinking (Claude 4+ default is summarized, cites
the EMNLP 2025 'Leaky Thoughts' paper for privacy implications)
- Excluded contexts (dream sessions, intermediate turns)
- All 8 auto-cleanup paths
- Bulk purge usage + raw SQL alternatives
- Disabling behavior (capture vs display split)
- Operational notes (storage growth, retry handling, race-window
safety, redacted thinking caveat)
- GET /agent/thinking API reference
5. **Self-isolating test.** runtime/test/web/thinking/persist-thinking.test.ts
now imports runtime/test/helpers.js as a side-effect, which forces
PICLAW_DB_IN_MEMORY=1 and uses a shared temp workspace. Runs cleanly
with `bun test` and no env vars.
- **UTF-16 surrogate-safe truncation** (`safeTruncateUtf16`): the cap
no longer splits emoji into orphan high/low surrogates.
- **`recovery_start` discard**: when the agent-pool retries internally
mid-turn, the failed attempt's thinking is discarded so only the
successful attempt's reasoning is persisted (avoids inflated metrics
and confusing combined reasoning).
- **Render consistency**: the persisted pill uses `renderThinkingMarkdown`
(same as the live thought panel) instead of the generic markdown
renderer. Avoids LaTeX-mangling `$VAR` shell content and matches the
streaming display exactly.
- **Index on `thinking_content.created_at`** for future retention queries.
- **ISO 8601 timestamp default**: `created_at` defaults to ISO Z format
so it's lex-comparable to messages.timestamp.
- **A11y rework of the pill (both UIs)**: now a real `<button>` with
`aria-expanded`, `aria-controls`, descriptive `aria-label`. The
detail region has `role=region` + `aria-live=polite`. Keyboard
focusable with visible focus outline. WCAG 2.1 AA compliant.
- **Visual UI parity**: chat_jid is threaded into the visual UI pill
via a fallback to the existing `getChatJid()` helper since the
visual UI's Interaction type doesn't carry chat_jid natively.
47 tests across 4 files, all pass:
- 12 in persist-thinking.test.ts (original config + CRUD)
- 10 in thinking-cleanup.test.ts (helpers + integration + orphan invariant)
- 16 in thinking-endpoint.test.ts (R2 truth table + IDOR + SQL injection)
- 9 in safe-truncate.test.ts (emoji boundaries + length invariants)
Runs with bare `bun test` (no env vars). Battle-tested live on Donna
(production PiClaw on v2.6.1 with PICLAW_WEB_PERSIST_THINKING=1 active
during all of the development work in this PR, including the $PATH /
piña-colada / thinking-pill-exercise tests that verified the render
consistency, the deletion-cleanup invariant, and the visual UI parity
in real usage).
717e13f to
61a7408
Compare
v2 — addresses all 5 review items + self-found improvements
This update force-pushes a rebased + squashed v2 that addresses every concern from the previous review, plus a number of issues found while battle-testing on a live PiClaw instance (Donna, running this PR's code in production for ~24h with
PICLAW_WEB_PERSIST_THINKING=1).Rebased onto
v2.6.2Clean rebase, zero merge conflicts.
Review items — point-by-point response
1. ✅ Avoid content-based rediscovery of the stored message row
Fixed.
storeAgentTurn()signature changed frombooleantonumber | null(returns the persisted message rowid). All 3 call sites inagent.tsupdated. The previousmaybePersistThinkingForStoredTurn()is renamedpersistThinkingForRow(rowId)and now takes the rowid directly — the fragilecontent_blocks LIKE '%"thinking_ref"%'rediscovery query and the duplicated thread/no-thread branches are deleted.Bonus: the LIKE was doing a full scan of
messagesfor the chat on every terminal turn (no index can cover aLIKE '%...%'on JSON text). Removing it is also a per-turn O(N) perf win, meaningful on long-lived chats.2. ✅ Validate
/agent/thinkingagainst the message tableFixed. Endpoint now requires both
message_idANDchat_jid. NewgetThinkingContentForChat(chatJid, messageId)runs a single JOIN that asserts:chat_jidis_bot_message = 1content_blockscontains an actualthinking_refblock — checked viaEXISTS (SELECT 1 FROM json_each(...) WHERE json_extract(value, '$.type') = 'thinking_ref')(not substring LIKE, which would be a confused-deputy primitive if any path ever lands the literal string"thinking_ref"in unrelated content)thinking_contentrow existsReturns 404 uniformly for any failure — no enumeration oracle, no information leak about which prong failed. Returns 400 only for missing required params. Both UIs (classic + visual) now send
chat_jidin the fetch URL.Covered by 16 unit tests in
runtime/test/web/thinking/thinking-endpoint.test.tsincluding a 6-case truth table over (correct chat / is bot / has ref block / has thinking row) and SQL-injection safety.3. ✅ Cleanup / retention semantics
Fixed in 8 places — every code path that deletes from
messagesnow also deletes the matchingthinking_contentrows:deleteMessageByRowId(UI single delete)db/messages.ts— wrapped indb.transaction(...)deleteThreadByRowId(UI thread delete)db/messages.ts— wrapped indb.transaction(...)rollbackChatRunWithError(cursor rollback on error)db/chat-cursors.tsrollbackInflightRun(crash recovery rollback)db/chat-cursors.tspermanentDeleteArchivedBranch(branch wipe)db/chat-branches.tsreapDreamArtifacts× 2 (stale dream sweep, full + exclude-one)dream.tscleanupDreamChat(specific dream cleanup)dream.tsNo FK CASCADE is possible:
thinking_content.message_idis TEXT andmessageshas a composite PK(id, chat_jid), so SQLite refuses to declare a CASCADE FK onrowid. Manual cleanup matches the existingmessage_mediapattern.The atomic-transaction wrapping on the two UI-delete paths specifically prevents a rowid-reuse ghost: SQLite reuses the highest deleted rowid on next INSERT, so a crash between deleting
thinking_contentandmessagescould cause a future message to inherit ghost thinking. Wrapping indb.transaction(...)closes that window.Retention purge:
runtime/scripts/purge-thinking-content.tsprovides a CLI (--all,--older-than-days N,--chat-jid JID) with safe dry-run-by-default, batched delete in a single transaction, optional--vacuumto reclaim space. An indexidx_thinking_content_created_atmakes time-based purges index-only.Covered by 10 unit tests in
runtime/test/web/thinking/thinking-cleanup.test.tsincluding an invariant test that runs 4 delete scenarios and asserts orphan count = 0 after each.4. ✅ Privacy and storage documentation
Fixed. New
docs/thinking-persistence.md(≈300 lines) covers:PICLAW_WEB_PERSIST_THINKING,PICLAW_WEB_PERSIST_THINKING_MAX_CHARS)thinking_contentwith type + meaningthinking.display: "summarized". What gets persisted is already a condensed summary, not raw internal reasoning.Linked from
README.mdOther-references section anddocs/configuration.mdenv-var table.5. ✅ Self-isolating test
Fixed.
runtime/test/web/thinking/persist-thinking.test.tsnow does:which is the same pattern other tests in the repo use. Runs cleanly with bare
bun test— no env var required. The 18-pass / 0-fail count from the maintainer's verification command is preserved.Additional self-found improvements
These weren't in the review but surfaced during my audit / 60+ self-Q&A questions in the style of three expert-panel rounds:
thinking_contentINSERT → fast-click 404 raceonMessageStoredcallback that runs BEFORE broadcasttext.slice(0, maxChars)could split UTF-16 surrogate pairs (emoji at boundary)safeTruncateUtf16helper + 9 unit testsrenderMarkdownwhile live streaming usesrenderThinkingMarkdown(inconsistent: $VAR mangled as LaTeX in pill, fine in live)renderThinkingMarkdownin both UIscreated_atfor future retention queriesidx_thinking_content_created_atrecovery_starteventchat_jidin fetch URL (broken by my own R2 fix)chatJidprop +getChatJid()fallbackcreated_atdefaulted todatetime('now')(no T, no Z, no millis) — inconsistent withmessages.timestampwhich is ISO Z; would silently break lex-comparison if any future query joined themstrftime('%Y-%m-%dT%H:%M:%fZ', 'now')LIKE '%"thinking_ref"%'was matching JSON text by substring — confused-deputy if any path lands the literal string in unrelated content_blocksjson_each+json_extractpredicate (see R2 above)<div onClick>, not keyboard-activatable, with a nested<button>(invalid HTML), noaria-expanded/aria-controls/aria-live— WCAG 2.1 SC 2.1.1 + 4.1.2 failures<button>for the disclosure (sibling to the copy button, both in a flex.controlswrapper). Full ARIA:aria-expanded,aria-controls→ stable detail id, descriptivearia-label. Detail region hasrole=region+aria-live=polite. CSS adds:focus-visibleoutline + button reset. Applied to both classic and visual UIs.Test results
Breakdown of the 47 thinking tests:
persist-thinking.test.ts— 12 (original config + CRUD)thinking-cleanup.test.ts— 10 (helpers + integration + orphan invariant)thinking-endpoint.test.ts— 16 (R2 truth table + IDOR + SQL injection)safe-truncate.test.ts— 9 (emoji boundaries + length invariants)Plus the existing
agent-message-store.test.ts(6 tests including 3 that neededexpect(ok).toBe(true)→expect(typeof ok === "number" && ok > 0).toBe(true)for the new return-type contract).Battle-tested live
The implementation has been running on a personal PiClaw instance (Donna, v2.6.2 base + this PR) since the start of development, with
PICLAW_WEB_PERSIST_THINKING=1active. Verified live:$PATHtest confirmedrenderThinkingMarkdownfix (no LaTeX mangling)