Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dbc9760
feat(cli): surface auto-memory dream tasks in Background tasks dialog
wenshao May 4, 2026
c6118b5
docs(cli): rephrase dream filter comment to focus on extract vs dream
wenshao May 4, 2026
3d8b978
feat(core,cli): cancel dream consolidation tasks via dialog and task_…
wenshao May 4, 2026
60568f1
fix(core,cli): address review feedback on dream cancellation surface
wenshao May 4, 2026
70b5761
fix(core): handle resolved-cancel path from runForkedAgent for dream …
wenshao May 4, 2026
50c0d55
fix(core,cli): address review feedback on dream UX, perf, and comment…
wenshao May 5, 2026
e6711c5
fix(core,cli): tighten cancelTask contract, dedup dream snapshot read…
wenshao May 5, 2026
5cc1582
fix(core,cli): address self-review + Copilot feedback on dream surface
wenshao May 5, 2026
2b87c6f
fix(core,cli): tighten error semantics + comments on dream surface
wenshao May 5, 2026
4ebd0b7
fix(core): skip scheduleDream early when params.config is missing
wenshao May 5, 2026
5de1a78
fix(core): plug subscribe Map leak + dream.ts late-cancel ordering bug
wenshao May 5, 2026
a139b07
fix(core): swallow releaseDreamLock errors so they don't poison outcome
wenshao May 5, 2026
a8664a9
fix(core): thread abortSignal into dream metadata writes
wenshao May 5, 2026
9b4df07
fix(core): close dream cancel race + surface lock-release failures
wenshao May 5, 2026
b00ecde
fix(core,cli): address 8 review findings on dream surface
wenshao May 5, 2026
90fffbf
fix(memory): same-session recovery when releaseDreamLock throws
wenshao May 5, 2026
90df8f4
fix(memory): real cancelled-dream duration + clarify index-rebuild or…
wenshao May 5, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { ConfigContext } from '../../contexts/ConfigContext.js';
import {
type AgentDialogEntry,
type DreamDialogEntry,
useBackgroundTaskView,
type DialogEntry,
} from '../../hooks/useBackgroundTaskView.js';
Expand All @@ -36,6 +37,8 @@ vi.mock('../../hooks/useBackgroundTaskView.js', () => ({
return entry.shellId;
case 'monitor':
return entry.monitorId;
case 'dream':
return entry.dreamId;
default: {
const _exhaustive: never = entry;
throw new Error(
Expand All @@ -53,7 +56,7 @@ vi.mock('../../hooks/useKeypress.js', () => ({
const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView);
const mockedUseKeypress = vi.mocked(useKeypress);

function entry(overrides: Partial<AgentDialogEntry> = {}): DialogEntry {
function entry(overrides: Partial<AgentDialogEntry> = {}): AgentDialogEntry {
return {
kind: 'agent',
agentId: 'a',
Expand All @@ -62,7 +65,21 @@ function entry(overrides: Partial<AgentDialogEntry> = {}): DialogEntry {
startTime: 0,
abortController: new AbortController(),
...overrides,
} as DialogEntry;
} as AgentDialogEntry;
}

function dreamEntry(
overrides: Partial<DreamDialogEntry> = {},
): DreamDialogEntry {
return {
kind: 'dream',
dreamId: 'd-1',
status: 'running',
startTime: 0,
sessionCount: 7,
progressText: 'Scheduled managed auto-memory dream.',
...overrides,
};
}

function monitorEntry(overrides: Partial<DialogEntry> = {}): DialogEntry {
Expand Down Expand Up @@ -94,6 +111,7 @@ interface Harness {
resume: ReturnType<typeof vi.fn>;
abandon: ReturnType<typeof vi.fn>;
monitorCancel: ReturnType<typeof vi.fn>;
dreamCancelTask: ReturnType<typeof vi.fn>;
setEntries: (next: readonly DialogEntry[]) => void;
pressKey: (key: { name?: string; sequence?: string }) => void;
call: (fn: () => void) => void;
Expand All @@ -112,6 +130,7 @@ function setup(initial: readonly DialogEntry[]): Harness {
const resume = vi.fn();
const abandon = vi.fn();
const monitorCancel = vi.fn();
const dreamCancelTask = vi.fn();
// Stub registry that resolves `.get(agentId)` against the current entries
// snapshot — the dialog now re-reads agent entries via `.get()` to pick up
// live activity/stats mutations the snapshot misses.
Expand All @@ -138,6 +157,9 @@ function setup(initial: readonly DialogEntry[]): Harness {
return match;
},
}),
getMemoryManager: () => ({
cancelTask: dreamCancelTask,
}),
resumeBackgroundAgent: resume,
abandonBackgroundAgent: abandon,
} as unknown as Config;
Expand Down Expand Up @@ -182,6 +204,7 @@ function setup(initial: readonly DialogEntry[]): Harness {
resume,
abandon,
monitorCancel,
dreamCancelTask,
setEntries(next) {
handlers.length = 0;
currentEntries = next;
Expand Down Expand Up @@ -447,4 +470,119 @@ describe('BackgroundTasksDialog', () => {
expect(f).not.toContain('Stopped because');
});
});

describe('dream entries', () => {
// Smoke for the PR-1 read-only surface — dream tasks render through
// the unified pill / dialog plumbing but cannot yet be cancelled.
// PR-2 lands MemoryManager.cancelTask + task_stop dispatch + the
// 'x stop' affordance.
Comment thread
wenshao marked this conversation as resolved.
Resolved
it('renders the [dream] row with session count in list mode', () => {
const h = setup([dreamEntry({ sessionCount: 7 })]);
h.call(() => h.probe.current!.actions.openDialog());

const f = h.lastFrame() ?? '';
expect(f).toContain('[dream]');
expect(f).toContain('memory consolidation');
expect(f).toContain('reviewing 7 sessions');
});

it('renders DreamDetailBody with sessions / progress / topics on detail view', () => {
const h = setup([
dreamEntry({
status: 'completed',
sessionCount: 5,
progressText: 'Managed auto-memory dream completed.',
touchedTopics: ['user', 'project', 'feedback'],
}),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());

const f = h.lastFrame() ?? '';
expect(f).toContain('Dream');
expect(f).toContain('Sessions reviewing');
expect(f).toContain('5');
expect(f).toContain('Progress');
expect(f).toContain('Managed auto-memory dream completed.');
expect(f).toContain('Topics touched (3)');
expect(f).toContain('user');
expect(f).toContain('project');
expect(f).toContain('feedback');
});

it('shows the "x stop" hint for a running dream entry', () => {
const h = setup([dreamEntry({ status: 'running' })]);
h.call(() => h.probe.current!.actions.openDialog());
const f = h.lastFrame() ?? '';
expect(f).toContain('x stop');
});

it("routes 'x' on a running dream to MemoryManager.cancelTask(dreamId)", () => {
// Pin the dream-cancel branch in `cancelSelected` — flipping it
// to anything else (e.g. shell's `requestCancel`) would silently
// break the only path the user has to stop a runaway dream
// consolidation, since the hint already advertises the action.
const h = setup([dreamEntry({ dreamId: 'd-zzz', status: 'running' })]);
h.call(() => h.probe.current!.actions.openDialog());
h.pressKey({ sequence: 'x' });
expect(h.dreamCancelTask).toHaveBeenCalledWith('d-zzz');
// Belt-and-braces — the registry-side cancel paths must not fire
// for a dream entry, otherwise the wrong AbortController gets
// signalled.
expect(h.cancel).not.toHaveBeenCalled();
expect(h.monitorCancel).not.toHaveBeenCalled();
});

it('omits the topics block entirely while the dream is still running', () => {
// Topics only get populated via metadata.touchedTopics on
// completion; mid-run the body should hide the section instead of
// rendering an empty header.
const h = setup([dreamEntry({ status: 'running', touchedTopics: [] })]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
expect(f).not.toContain('Topics touched');
});

it('renders the Error block on failed status with a "+ Stopped because" verb', () => {
// Dream failures need to surface — they are the user's only signal
// that consolidation didn't happen as expected (success path
// already produces a memory_saved toast in useGeminiStream).
const h = setup([
dreamEntry({
status: 'failed',
error: 'Dream agent failed: model timeout',
}),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
expect(f).toContain('Failed');
expect(f).toContain('Error');
expect(f).toContain('Dream agent failed: model timeout');
});

it('caps visible topics at 8 and renders a "+N more" tail for overflow', () => {
// Real consolidations can touch many memory files; the body must
// not push the hint footer off-screen. Cap mirrors MAX_TOPICS in
// DreamDetailBody.
const manyTopics = Array.from({ length: 12 }, (_, i) => `topic-${i + 1}`);
const h = setup([
dreamEntry({ status: 'completed', touchedTopics: manyTopics }),
]);
h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());
const f = h.lastFrame() ?? '';
// First 8 visible.
expect(f).toContain('topic-1');
expect(f).toContain('topic-8');
// Past the cap — must NOT be inlined.
expect(f).not.toContain('topic-9');
expect(f).not.toContain('topic-12');
// Tail summary.
expect(f).toContain('+4 more');
// Header still reflects the full count, not the capped slice.
expect(f).toContain('Topics touched (12)');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { formatDuration, formatTokenCount } from '../../utils/formatters.js';
import {
type AgentDialogEntry,
type DialogEntry,
type DreamDialogEntry,
entryId,
} from '../../hooks/useBackgroundTaskView.js';

Expand Down Expand Up @@ -118,6 +119,13 @@ function rowLabel(entry: DialogEntry): string {
return `[shell] ${entry.command}`;
case 'monitor':
return `[monitor] ${entry.description}`;
case 'dream': {
const sessionsHint =
entry.sessionCount !== undefined
? ` reviewing ${entry.sessionCount} session${entry.sessionCount === 1 ? '' : 's'}`
: '';
return `[dream] memory consolidation${sessionsHint}`;
}
default: {
const _exhaustive: never = entry;
throw new Error(
Expand Down Expand Up @@ -282,6 +290,14 @@ const DetailBody: React.FC<{
maxWidth={maxWidth}
/>
);
case 'dream':
return (
<DreamDetailBody
entry={entry}
maxHeight={maxHeight}
maxWidth={maxWidth}
/>
);
default: {
const _exhaustive: never = entry;
throw new Error(
Expand All @@ -291,6 +307,135 @@ const DetailBody: React.FC<{
}
};

// ─── Dream detail body ─────────────────────────────────────
//
// Read-only for now: dream cancellation lands in the PR-2 follow-up
// alongside `task_stop` integration. The body shows what the agent is
// reviewing (session count), what it has touched (topic files, only
// after completion), and the latest progress text from MemoryManager.
Comment thread
wenshao marked this conversation as resolved.
Resolved
//
// Layout follows the Shell/Monitor convention — flat children of
// MaxSizedBox separated by empty `<Box />` spacers (nesting a
// `flexDirection="column"` container inside MaxSizedBox eats the
// children silently).
const DreamDetailBody: React.FC<{
entry: DreamDialogEntry;
maxHeight: number;
maxWidth: number;
}> = ({ entry, maxHeight, maxWidth }) => {
const title = 'Dream';
const terminal = terminalStatusPresentation(entry.status);
const dimSubtitleParts: string[] = [elapsedFor(entry)];
if (entry.sessionCount !== undefined) {
dimSubtitleParts.push(
`${entry.sessionCount} session${entry.sessionCount === 1 ? '' : 's'}`,
);
}
if (entry.touchedTopics && entry.touchedTopics.length > 0) {
dimSubtitleParts.push(
`${entry.touchedTopics.length} topic${entry.touchedTopics.length === 1 ? '' : 's'}`,
);
}

// Topic file lists can grow for an active session sweep; cap the
// displayed slice and add a "+N more" tail rather than letting the
// dialog body push the hint footer off-screen.
const MAX_TOPICS = 8;
const topics = entry.touchedTopics ?? [];
const visibleTopics = topics.slice(0, MAX_TOPICS);
const hiddenTopicCount = Math.max(0, topics.length - visibleTopics.length);
const hasError = Boolean(entry.error);

return (
<MaxSizedBox
maxHeight={maxHeight}
maxWidth={maxWidth}
overflowDirection="bottom"
>
<Box>
<Text bold color={theme.text.accent}>
{title}
</Text>
</Box>
<Box>
{terminal && (
<Text color={terminal.color}>
{`${terminal.icon} ${STATUS_VERBS[entry.status]} · `}
</Text>
)}
<Text color={theme.text.secondary}>{dimSubtitleParts.join(' · ')}</Text>
</Box>

{entry.sessionCount !== undefined && (
<Fragment>
<Box />
<Box>
<Text bold dimColor>
Sessions reviewing
</Text>
</Box>
<Box>
<Text>{String(entry.sessionCount)}</Text>
</Box>
</Fragment>
)}

{entry.progressText && (
<Fragment>
<Box />
<Box>
<Text bold dimColor>
Progress
</Text>
</Box>
<Box>
<Text wrap="wrap">{entry.progressText}</Text>
</Box>
</Fragment>
)}

{topics.length > 0 && (
<Fragment>
<Box />
<Box>
<Text bold dimColor>
{`Topics touched (${topics.length})`}
</Text>
</Box>
{visibleTopics.map((topic) => (
<Box key={topic}>
<Text>{` · ${topic}`}</Text>
</Box>
))}
{hiddenTopicCount > 0 && (
<Box>
<Text
color={theme.text.secondary}
>{` · +${hiddenTopicCount} more`}</Text>
</Box>
)}
</Fragment>
)}

{hasError && (
<Fragment>
<Box />
<Box>
<Text bold color={theme.status.error}>
Error
</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{entry.error}
</Text>
</Box>
</Fragment>
)}
</MaxSizedBox>
);
};

const AgentDetailBody: React.FC<{
entry: AgentDialogEntry;
maxHeight: number;
Expand Down
Loading
Loading