Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 478 additions & 0 deletions docs/superpowers/plans/2026-04-30-p4-compaction-todo-preservation.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { render } from 'ink-testing-library';
import { ThinkMessage, ThinkMessageContent } from './ConversationMessages.js';

describe('ThinkMessage', () => {
it('renders the streaming text expanded while pending', () => {
const { lastFrame } = render(
<ThinkMessage
text="Let me consider this carefully and weigh the options."
isPending={true}
contentWidth={80}
/>,
);
const output = lastFrame() ?? '';
expect(output).toContain('Let me consider this');
// Streaming render uses the existing ⟡ glyph, not the ▸ summary marker.
expect(output).toContain('⟡');
expect(output).not.toContain('thinking (');
});

it('renders compact "thinking (N chars)" summary once stream finalizes', () => {
const text = 'reasoning '.repeat(10).trim(); // 99 chars
const { lastFrame } = render(
<ThinkMessage text={text} isPending={false} contentWidth={80} />,
);
const output = lastFrame() ?? '';
expect(output).toContain('▸');
expect(output).toContain(`thinking (${text.length} chars)`);
// Underlying reasoning text is not rendered inline post-stream.
expect(output).not.toContain('reasoning reasoning');
});

it('formats large char counts with thousands separator', () => {
const text = 'x'.repeat(12_345);
const { lastFrame } = render(
<ThinkMessage text={text} isPending={false} contentWidth={80} />,
);
const output = lastFrame() ?? '';
expect(output).toContain('thinking (12,345 chars)');
});
});

describe('ThinkMessageContent', () => {
it('renders the continuation text while pending', () => {
const { lastFrame } = render(
<ThinkMessageContent
text="continued reasoning text"
isPending={true}
contentWidth={80}
/>,
);
const output = lastFrame() ?? '';
expect(output).toContain('continued reasoning text');
});

it('renders nothing once stream finalizes (the parent ThinkMessage owns the summary)', () => {
const { lastFrame } = render(
<ThinkMessageContent
text="continued reasoning text"
isPending={false}
contentWidth={80}
/>,
);
const output = lastFrame() ?? '';
expect(output.trim()).toBe('');
});
});
77 changes: 56 additions & 21 deletions packages/cli/src/ui/components/messages/ConversationMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,35 +227,70 @@ export const AssistantMessageContent: React.FC<
/>
);

// Post-stream summary line ("▸ thinking (N chars)"). Phase 1 of the reasoning
// rendering work (see #162): full text remains live in Langfuse, ACP
// `agent_thought_chunk` notifications, and ChatRecord for back-compat. An
// in-TUI expand affordance is a follow-up. Note: when a long thought was
// split mid-stream into a gemini_thought + gemini_thought_content pair, this
// counts only the first chunk — the continuation renders nothing (see below).
// True total requires post-finalize coalescing in useGeminiStream and is
// deferred since splits are rare and the count is a hint, not a contract.
const ThinkSummary: React.FC<{ text: string }> = ({ text }) => {
const charCount = text.length;
return (
<PrefixedTextMessage
text={`thinking (${charCount.toLocaleString()} chars)`}
prefix="▸"
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
);
};

export const ThinkMessage: React.FC<ThinkMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="⟡"
prefixColor={theme.text.secondary}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
textColor={theme.text.secondary}
/>
);
}) => {
if (!isPending) {
return <ThinkSummary text={text} />;
}
return (
<PrefixedMarkdownMessage
text={text}
prefix="⟡"
prefixColor={theme.text.secondary}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
textColor={theme.text.secondary}
/>
);
};

export const ThinkMessageContent: React.FC<ThinkMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="⟡"
textColor={theme.text.secondary}
/>
);
}) => {
// When the stream has finalized, suppress the continuation block. The
// adjacent ThinkMessage already renders the summary line; rendering this
// continuation as another summary would double-count and drop chars across
// the split boundary. Streaming-time renders unchanged so live thoughts
// still appear.
if (!isPending) {
return null;
}
return (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="⟡"
textColor={theme.text.secondary}
/>
);
};
12 changes: 8 additions & 4 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,14 @@ export class AgentCore {
messagesAfter: masked.length,
});
}
const compacted =
estimateTokens(masked) <= targetTokens
? masked
: compactMessages(masked, targetTokens);
let compacted: Content[];
if (estimateTokens(masked) <= targetTokens) {
compacted = masked;
} else {
const taskStore = this.runtimeContext.getTaskStore?.();
const result = compactMessages(masked, targetTokens, { taskStore });
compacted = result instanceof Promise ? await result : result;
}
Comment on lines +474 to +481
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the task-plan summary on the masking-only path.

When observation masking already gets masked under targetTokens, this branch returns masked directly and never calls compactMessages(...), so the new <task-plan> enrichment is skipped on those compaction passes. That defeats the preservation behavior in a common case.

Possible fix
-          let compacted: Content[];
-          if (estimateTokens(masked) <= targetTokens) {
-            compacted = masked;
-          } else {
-            const taskStore = this.runtimeContext.getTaskStore?.();
-            const result = compactMessages(masked, targetTokens, { taskStore });
-            compacted = result instanceof Promise ? await result : result;
-          }
+          const taskStore = this.runtimeContext.getTaskStore?.();
+          const shouldCompact =
+            estimateTokens(masked) > targetTokens || !!taskStore;
+          const compacted = shouldCompact
+            ? await compactMessages(masked, targetTokens, { taskStore })
+            : masked;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agents/runtime/agent-core.ts` around lines 474 - 481, The
current early-return when estimateTokens(masked) <= targetTokens skips the
`<task-plan>` enrichment that compactMessages(...) applies; instead of returning
masked directly, always invoke compactMessages(masked, targetTokens, {
taskStore: this.runtimeContext.getTaskStore?.() }) and assign compacted to its
result (await if it returns a Promise) so the task-plan summary enrichment runs
even when masking already meets the token target; use the existing variables
masked, targetTokens, taskStore and preserve the async handling used in the else
branch.

if (compacted.length < historyBefore.length) {
chat.setHistory(compacted);
const tokensAfter = estimateTokens(compacted);
Expand Down
Loading
Loading