From eb8e053567ff0ea05aa14e8b070f658fb4430aa8 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 10 Jun 2026 23:23:13 -0700 Subject: [PATCH] fix(cli): keep labeled system turns visible in history replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the system_turn rendering change: heartbeat triggers and channel-delivered messages moved from type:user to type:system_turn, which the tailPreview replay didn't include — on reattach, Myra's answers appeared with no questions above them. - tailPreview gains a 'system' role with label; system_turn events and kept-tail system entries with channel labels (heartbeat, telegram) replay with their label - Internal bookkeeping sources (continuation prompts, activity echoes, passive recall, budget monitor) stay in the ledger but out of the visible replay — INTERNAL_SYSTEM_SOURCES set - Replay renderer maps system entries to their channel label Adds 2 regression tests (labeled system turns in preview + through compaction keptEntries, internal sources excluded). Co-Authored-By: Wren --- .../cli/src/commands/chat-hydration.test.ts | 49 ++++++++++++ packages/cli/src/commands/chat.ts | 76 ++++++++++++++++--- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/chat-hydration.test.ts b/packages/cli/src/commands/chat-hydration.test.ts index 609761c9..5e09fc60 100644 --- a/packages/cli/src/commands/chat-hydration.test.ts +++ b/packages/cli/src/commands/chat-hydration.test.ts @@ -169,6 +169,55 @@ describe('hydrateLedgerFromTranscript — compaction events', () => { expect(previewContents.filter((c) => c === 'kept question')).toHaveLength(1); }); + it('includes labeled system turns in tailPreview, excluding continuation noise', () => { + // Heartbeat triggers / channel-delivered messages must stay visible in + // the replay (regression: moving them from type:user to type:system_turn + // dropped them from the preview — answers appeared without questions). + writeTranscript([ + { type: 'system_turn', content: '[HEARTBEAT TRIGGER] check email', label: 'heartbeat' }, + { type: 'assistant', content: 'heartbeat cycle complete', backend: 'claude' }, + { + type: 'system_turn', + content: 'Continue working. Use signal_status…', + label: 'continuation', + }, + { type: 'assistant', content: 'still done', backend: 'claude' }, + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + + const previews = result.tailPreview.map((p) => ({ role: p.role, label: p.label })); + expect(result.tailPreview[0].content).toContain('[HEARTBEAT TRIGGER]'); + expect(previews[0]).toEqual({ role: 'system', label: 'heartbeat' }); + // Continuation prompts excluded from replay; both assistant replies kept + expect(result.tailPreview.filter((p) => p.label === 'continuation')).toHaveLength(0); + expect(result.tailPreview.filter((p) => p.role === 'assistant')).toHaveLength(2); + }); + + it('keeps labeled system turns visible through compaction keptEntries', () => { + writeTranscript([ + { type: 'user', content: 'old' }, + { + type: 'compaction', + summary: 'the summary', + keptEntries: [ + { role: 'system', content: '[HEARTBEAT TRIGGER] hourly check', source: 'heartbeat' }, + { role: 'assistant', content: 'cycle complete', source: 'claude' }, + { role: 'system', content: 'internal echo', source: 'pcp-activity' }, + ], + }, + ]); + + const ledger = new ContextLedger(); + const result = hydrateLedgerFromTranscript(ledger, transcriptPath); + + const labels = result.tailPreview.map((p) => p.label || p.role); + expect(labels).toContain('heartbeat'); + expect(labels).not.toContain('pcp-activity'); // internal sources stay out of replay + expect(result.tailPreview.map((p) => p.content)).not.toContain('old'); + }); + it('skips malformed keptEntries without crashing', () => { writeTranscript([ { diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 2e0ed703..6b506465 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -387,7 +387,13 @@ interface HistoryHydrationResult { messageCount: number; source: 'repl-transcript' | 'pcp-session-context' | 'none'; transcriptPath?: string; - tailPreview: Array<{ role: 'user' | 'assistant' | 'inbox'; content: string; ts?: string }>; + tailPreview: Array<{ + role: 'user' | 'assistant' | 'inbox' | 'system'; + content: string; + ts?: string; + /** Display label for system entries (e.g., "heartbeat", "continuation") */ + label?: string; + }>; seenInboxIds?: string[]; seenActivityIds?: string[]; /** True when hydration collapsed history at a compaction event */ @@ -619,8 +625,13 @@ export function hydrateLedgerFromTranscript( // (and only them; entries that pre-date hydration are left alone). const hydratedEntryIds: number[] = []; - const pushPreview = (role: 'user' | 'assistant' | 'inbox', content: string, ts?: string) => { - preview.push({ role, content: compactForHistoryPreview(role, content), ts }); + const pushPreview = ( + role: 'user' | 'assistant' | 'inbox' | 'system', + content: string, + ts?: string, + label?: string + ) => { + preview.push({ role, content: compactForHistoryPreview(role, content), ts, label }); if (preview.length > HISTORY_PREVIEW_MAX) { preview.shift(); } @@ -657,11 +668,9 @@ export function hydrateLedgerFromTranscript( keptRecord.role === 'system' ? keptRecord.role : 'system'; - const entry = ledger.addEntry( - role, - keptRecord.content, - typeof keptRecord.source === 'string' ? keptRecord.source : 'compaction-tail' - ); + const source = + typeof keptRecord.source === 'string' ? keptRecord.source : 'compaction-tail'; + const entry = ledger.addEntry(role, keptRecord.content, source); hydratedEntryIds.push(entry.id); loaded += 1; if (role === 'user' || role === 'assistant' || role === 'inbox') { @@ -671,6 +680,15 @@ export function hydrateLedgerFromTranscript( keptRecord.content, typeof event.ts === 'string' ? event.ts : undefined ); + } else if (role === 'system' && !INTERNAL_SYSTEM_SOURCES.has(source)) { + // Kept system turns with a meaningful channel label (heartbeat, + // telegram, …) stay visible in the replay + pushPreview( + 'system', + keptRecord.content, + typeof event.ts === 'string' ? event.ts : undefined, + source + ); } } continue; @@ -712,6 +730,16 @@ export function hydrateLedgerFromTranscript( hydratedEntryIds.push(entry.id); loaded += 1; messageCount += 1; + // Continuation prompts are repetitive noise — keep delivered messages + // (heartbeat triggers, channel messages) visible in the replay. + if (label !== 'continuation') { + pushPreview( + 'system', + event.content, + typeof event.ts === 'string' ? event.ts : undefined, + label + ); + } continue; } if (type === 'hook_injection' && typeof event.content === 'string') { @@ -835,7 +863,25 @@ function compactForLedger(content: string, maxChars = LEDGER_COMPACT_CHARS): str return `${normalized.slice(0, Math.max(1, maxChars - 1))}…`; } -function compactForHistoryPreview(role: 'user' | 'assistant' | 'inbox', content: string): string { +// System-entry sources that are runtime bookkeeping, not conversation — +// excluded from the visible history replay (they stay in the ledger). +const INTERNAL_SYSTEM_SOURCES = new Set([ + 'continuation', + 'compaction-tail', + 'compaction-history', + 'pcp-activity', + 'pcp-activity-history', + 'passive-recall', + 'budget-monitor', + 'auto-run', + 'hook-history', + 'bootstrap', +]); + +function compactForHistoryPreview( + role: 'user' | 'assistant' | 'inbox' | 'system', + content: string +): string { if (role === 'inbox') { return compactForLedger(content.replace(/\s+/g, ' ').trim(), 180); } @@ -4342,9 +4388,17 @@ export async function runChat(options: ChatOptions): Promise { ? ('user' as const) : entry.role === 'assistant' ? ('assistant' as const) - : ('inbox' as const); + : entry.role === 'system' + ? ('system' as const) + : ('inbox' as const); const label = - entry.role === 'user' ? 'you' : entry.role === 'assistant' ? agentId : '📬 inbox'; + entry.role === 'user' + ? 'you' + : entry.role === 'assistant' + ? agentId + : entry.role === 'system' + ? entry.label || 'system' + : '📬 inbox'; inkRepl.addMessage(role, entry.content, { label, time: formatHumanTime(entry.ts, runtime.userTimezone),