Skip to content

Commit 10275bc

Browse files
committed
feat: surface live agent plans
1 parent 76407df commit 10275bc

24 files changed

Lines changed: 507 additions & 5 deletions

src/__tests__/integration/core/run-agent.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,6 +1573,7 @@ describe('AgentRunService.run', () => {
15731573

15741574
it('allows a final answer even when a recorded plan still has unfinished items', async () => {
15751575
let stage = 0;
1576+
const events: AgentRunEvent[] = [];
15761577
const fakeLlm: LlmAdapter = {
15771578
async chat(): Promise<LlmResponse> {
15781579
stage += 1;
@@ -1615,6 +1616,7 @@ describe('AgentRunService.run', () => {
16151616
tools: [updatePlanTool],
16161617
maxSteps: 2,
16171618
logger: silentLogger,
1619+
onEvent: (event) => events.push(event),
16181620
});
16191621

16201622
expect(result.outcome).toBe('done');
@@ -1626,5 +1628,15 @@ describe('AgentRunService.run', () => {
16261628
message.content.includes('you recorded a plan and it still has unfinished items'),
16271629
),
16281630
).toBe(false);
1631+
expect(events).toContainEqual({
1632+
type: 'plan.updated',
1633+
step: 1,
1634+
explanation: 'Tracking the implementation steps.',
1635+
items: [
1636+
{ step: 'Inspect current implementation', status: 'completed' },
1637+
{ step: 'Implement the next bounded change', status: 'in_progress' },
1638+
{ step: 'Verify with tests', status: 'pending' },
1639+
],
1640+
});
16291641
});
16301642
});

src/__tests__/unit/cli-v2/control-plane-session-store.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,56 @@ describe('ControlPlaneSessionStore', () => {
498498
store.dispose();
499499
});
500500

501+
it('tracks active plan updates until the run finishes', async () => {
502+
const fixture = createClientFixture();
503+
const store = new ControlPlaneSessionStore({ client: fixture.client });
504+
await store.start();
505+
506+
fixture.sessionEvents?.onData?.({
507+
type: 'session.event',
508+
sessionId: 'session-1',
509+
timestamp: new Date().toISOString(),
510+
activities: [
511+
{
512+
source: 'agent-loop',
513+
type: 'plan.updated',
514+
runId: 'run-1',
515+
step: 1,
516+
timestamp: new Date().toISOString(),
517+
explanation: 'Tracking current work.',
518+
items: [
519+
{ step: 'Inspect', status: 'completed' },
520+
{ step: 'Implement', status: 'in_progress' },
521+
],
522+
},
523+
],
524+
} as ControlPlaneSessionEventEnvelope);
525+
526+
expect(store.getSnapshot().activePlan?.items).toEqual([
527+
{ step: 'Inspect', status: 'completed' },
528+
{ step: 'Implement', status: 'in_progress' },
529+
]);
530+
531+
fixture.sessionEvents?.onData?.({
532+
type: 'session.event',
533+
sessionId: 'session-1',
534+
timestamp: new Date().toISOString(),
535+
activities: [
536+
{
537+
source: 'agent-loop',
538+
type: 'loop.finished',
539+
runId: 'run-1',
540+
outcome: 'done',
541+
summary: 'Done.',
542+
timestamp: new Date().toISOString(),
543+
},
544+
],
545+
} as ControlPlaneSessionEventEnvelope);
546+
547+
expect(store.getSnapshot().activePlan).toBeUndefined();
548+
store.dispose();
549+
});
550+
501551
it('keeps the final run outcome visible after loop completion', async () => {
502552
const fixture = createClientFixture();
503553
const store = new ControlPlaneSessionStore({ client: fixture.client });

src/__tests__/unit/client-shared/session-activity-service.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ describe('ClientSharedSessionActivityService', () => {
5959
expect(effects).toEqual(['finished:Run finished: done', 'workspace changed']);
6060
});
6161

62+
it('applies plan update effects without changing live status', () => {
63+
const effects: string[] = [];
64+
65+
ClientSharedSessionActivityService.applyActivity({
66+
type: 'plan.updated',
67+
runId: 'run-1',
68+
source: 'agent-loop',
69+
step: 1,
70+
timestamp: new Date().toISOString(),
71+
explanation: 'Tracking current work.',
72+
items: [
73+
{ step: 'Inspect', status: 'completed' },
74+
{ step: 'Implement', status: 'in_progress' },
75+
],
76+
} as ControlPlaneSessionActivity, {
77+
onPlanUpdated: (plan) => effects.push(plan.items[1]?.step ?? ''),
78+
onLiveStatus: () => effects.push('live status changed'),
79+
});
80+
81+
expect(effects).toEqual(['Implement']);
82+
});
83+
6284
it('uses derived tool labels when the API provides them', () => {
6385
expect(ClientSharedSessionActivityService.formatToolLabel({
6486
type: 'tool.approval_requested',
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/** @vitest-environment jsdom */
2+
3+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
import { AgentPlanPanel } from '../../../web-v2/components/conversation/AgentPlanPanel.js';
6+
import type { ClientSharedSessionPlan } from '../../../client-shared/services/session-activities/index.js';
7+
8+
describe('AgentPlanPanel', () => {
9+
afterEach(() => {
10+
cleanup();
11+
vi.restoreAllMocks();
12+
vi.unstubAllGlobals();
13+
});
14+
15+
it('renders the current plan summary and items', () => {
16+
const plan = createPlan();
17+
18+
render(
19+
<AgentPlanPanel plan={plan} />,
20+
);
21+
22+
expect(screen.getByText('Plan')).toBeTruthy();
23+
expect(screen.getAllByText('Implement plan UI')).toHaveLength(2);
24+
expect(screen.getByText('Inspect current path')).toBeTruthy();
25+
expect(screen.getByText('Verify behavior')).toBeTruthy();
26+
expect(screen.getByText('Plan').closest('details')?.open).toBe(true);
27+
});
28+
29+
it('defaults collapsed on mobile and can be expanded', () => {
30+
mockMobileViewport();
31+
32+
render(<AgentPlanPanel plan={createPlan()} />);
33+
34+
const details = screen.getByText('Plan').closest('details');
35+
expect(details?.open).toBe(false);
36+
37+
fireEvent.click(screen.getByText('Plan'));
38+
39+
expect(details?.open).toBe(true);
40+
});
41+
});
42+
43+
function createPlan(): ClientSharedSessionPlan {
44+
return {
45+
source: 'agent-loop',
46+
type: 'plan.updated',
47+
runId: 'run-1',
48+
step: 1,
49+
timestamp: new Date().toISOString(),
50+
explanation: 'Tracking current work.',
51+
items: [
52+
{ step: 'Inspect current path', status: 'completed' },
53+
{ step: 'Implement plan UI', status: 'in_progress' },
54+
{ step: 'Verify behavior', status: 'pending' },
55+
],
56+
};
57+
}
58+
59+
function mockMobileViewport(): void {
60+
vi.stubGlobal('matchMedia', vi.fn((query) => ({
61+
matches: query === '(max-width: 38rem)',
62+
media: query,
63+
onchange: null,
64+
addListener: vi.fn(),
65+
removeListener: vi.fn(),
66+
addEventListener: vi.fn(),
67+
removeEventListener: vi.fn(),
68+
dispatchEvent: vi.fn(),
69+
})));
70+
}

src/cli-v2/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import React, { useCallback, useEffect, useRef } from 'react';
55
import { Box, Text } from 'ink';
66
import { ApprovalPanel } from './components/ApprovalPanel.js';
7+
import { AgentPlanPanel } from './components/AgentPlanPanel.js';
78
import { CommandResultPanel } from './components/CommandResultPanel.js';
89
import { ConversationPanel } from './components/ConversationPanel.js';
910
import { ModelPickerPanel } from './components/ModelPickerPanel.js';
@@ -118,6 +119,7 @@ export function App({
118119
cancelling={snapshot.cancelling}
119120
onCancel={cancelRun}
120121
/>
122+
<AgentPlanPanel plan={snapshot.activePlan} />
121123
{pickers.model.query !== undefined ? (
122124
<ModelPickerPanel
123125
query={pickers.model.query}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import React from 'react';
2+
import { Box, Text } from 'ink';
3+
import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities/index.js';
4+
5+
const statusGlyphs = {
6+
pending: '○',
7+
in_progress: '●',
8+
completed: '✓',
9+
} satisfies Record<ClientSharedSessionPlan['items'][number]['status'], string>;
10+
11+
type AgentPlanPanelProps = {
12+
plan?: ClientSharedSessionPlan;
13+
};
14+
15+
export function AgentPlanPanel({ plan }: AgentPlanPanelProps) {
16+
if (!plan) {
17+
return null;
18+
}
19+
20+
return (
21+
<Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1} marginTop={1}>
22+
<Text bold>Plan</Text>
23+
{plan.explanation ? <Text color="gray">{plan.explanation}</Text> : null}
24+
{plan.items.map((item) => (
25+
<Text key={`${item.status}:${item.step}`} color={item.status === 'in_progress' ? 'cyan' : undefined}>
26+
{statusGlyphs[item.status]} {item.step}
27+
</Text>
28+
))}
29+
</Box>
30+
);
31+
}

src/cli-v2/state/control-plane-session-store.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ControlPlaneProxyClient } from '@/client-shared/api/proxy.js';
22
import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities/index.js';
3+
import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities/index.js';
34
import { ClientSharedSessionMessageService } from '@/client-shared/services/session-messages/index.js';
45
import {
56
SessionActivityService,
@@ -65,6 +66,7 @@ export type ControlPlaneSessionStoreSnapshot = {
6566
cancelling: boolean;
6667
streamConnected: boolean;
6768
liveStatus?: string;
69+
activePlan?: ClientSharedSessionPlan;
6870
latestUpdate?: ControlPlaneSessionLatestUpdate;
6971
slashCommandCatalog?: ControlPlaneSlashCommandCatalog;
7072
commandResults: ControlPlaneSlashCommandResult[];
@@ -92,6 +94,8 @@ const INITIAL_SNAPSHOT: ControlPlaneSessionStoreSnapshot = {
9294
* This is the non-React counterpart to web-v2's focused session hooks: it loads
9395
* the selected workspace/session, subscribes to live updates, keeps transient
9496
* conversation messages coherent, and exposes terminal intent methods.
97+
* Shared activity policy stays in client-shared; this store owns only cli-v2
98+
* state mutation and terminal workflow coordination.
9599
*/
96100
export class ControlPlaneSessionStore {
97101
private readonly api: ControlPlaneSessionApiService;
@@ -192,6 +196,7 @@ export class ControlPlaneSessionStore {
192196
runtimeContext: undefined,
193197
pendingApproval: null,
194198
liveStatus: undefined,
199+
activePlan: undefined,
195200
latestUpdate: undefined,
196201
error: undefined,
197202
loading: true,
@@ -243,6 +248,7 @@ export class ControlPlaneSessionStore {
243248
submitting: true,
244249
running: true,
245250
error: undefined,
251+
activePlan: undefined,
246252
liveStatus: current.streamConnected
247253
? 'Heddle is working...'
248254
: 'Heddle is working... reconnecting live stream if needed.',
@@ -487,6 +493,7 @@ export class ControlPlaneSessionStore {
487493
private async continueSession(workspaceId: string, sessionId: string): Promise<void> {
488494
this.setSnapshot({
489495
running: true,
496+
activePlan: undefined,
490497
liveStatus: 'Heddle is continuing from the current transcript...',
491498
});
492499
await this.api.continueSession(workspaceId, sessionId);
@@ -500,6 +507,7 @@ export class ControlPlaneSessionStore {
500507
await this.api.sendPromptAsync({ workspaceId, sessionId, prompt });
501508
this.setSnapshot({
502509
running: true,
510+
activePlan: undefined,
503511
liveStatus: this.snapshotValue.streamConnected
504512
? 'Heddle is working...'
505513
: 'Heddle is working... reconnecting live stream if needed.',
@@ -567,6 +575,12 @@ export class ControlPlaneSessionStore {
567575
onPendingApprovalChanged: () => {
568576
void this.refreshPendingApproval(event.sessionId);
569577
},
578+
onPlanUpdated: (plan) => {
579+
this.setSnapshot({ activePlan: plan });
580+
},
581+
onPlanCleared: () => {
582+
this.setSnapshot({ activePlan: undefined });
583+
},
570584
onLiveStatus: (statusActivity, liveStatus) => {
571585
const latestUpdate = SessionActivityService.resolveLatestUpdate(statusActivity);
572586
if (liveStatus === undefined && latestUpdate === undefined) {
@@ -683,6 +697,7 @@ export class ControlPlaneSessionStore {
683697
this.setSnapshot({
684698
submitting: false,
685699
liveStatus: undefined,
700+
activePlan: undefined,
686701
latestUpdate: {
687702
label: 'Run finished',
688703
tone: 'success',
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Session Activity Client Effects
2+
3+
This service owns frontend-neutral effects derived from control-plane session
4+
activities. It is the shared interpretation layer used by web-v2 and cli-v2
5+
after they receive API-provided live events.
6+
7+
## Owns
8+
9+
- Mapping each API activity type to shared client effects.
10+
- Shared live status copy for activity progress.
11+
- Shared derived labels for tool-related activities.
12+
- Active plan lifetime at the client edge: `plan.updated` sets the visible plan,
13+
`loop.started` and `loop.finished` clear it.
14+
15+
## Does Not Own
16+
17+
- Activity facts or schemas. Those belong to `src/core/live` and flow through
18+
tRPC-derived client types.
19+
- Plan parsing or validation. That belongs to the core agent planning/tool
20+
modules.
21+
- React state, Ink state, layout, or rendering.
22+
- Control-plane transport or subscription setup.
23+
24+
## Boundary
25+
26+
Do not add duplicated activity switchboards in web-v2 or cli-v2. Add a shared
27+
effect here when both clients need to react to the same control-plane activity.
28+
Client code should provide state setters and render the resulting state in its
29+
own UI language.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
22
ClientSharedSessionActivityService,
33
type ClientSharedSessionActivity,
4+
type ClientSharedSessionPlan,
45
} from './session-activity-service.js';

0 commit comments

Comments
 (0)