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
12 changes: 12 additions & 0 deletions src/__tests__/integration/core/run-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,7 @@ describe('AgentRunService.run', () => {

it('allows a final answer even when a recorded plan still has unfinished items', async () => {
let stage = 0;
const events: AgentRunEvent[] = [];
const fakeLlm: LlmAdapter = {
async chat(): Promise<LlmResponse> {
stage += 1;
Expand Down Expand Up @@ -1615,6 +1616,7 @@ describe('AgentRunService.run', () => {
tools: [updatePlanTool],
maxSteps: 2,
logger: silentLogger,
onEvent: (event) => events.push(event),
});

expect(result.outcome).toBe('done');
Expand All @@ -1626,5 +1628,15 @@ describe('AgentRunService.run', () => {
message.content.includes('you recorded a plan and it still has unfinished items'),
),
).toBe(false);
expect(events).toContainEqual({
type: 'plan.updated',
step: 1,
explanation: 'Tracking the implementation steps.',
items: [
{ step: 'Inspect current implementation', status: 'completed' },
{ step: 'Implement the next bounded change', status: 'in_progress' },
{ step: 'Verify with tests', status: 'pending' },
],
});
});
});
50 changes: 50 additions & 0 deletions src/__tests__/unit/cli-v2/control-plane-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,56 @@ describe('ControlPlaneSessionStore', () => {
store.dispose();
});

it('tracks active plan updates until the run finishes', async () => {
const fixture = createClientFixture();
const store = new ControlPlaneSessionStore({ client: fixture.client });
await store.start();

fixture.sessionEvents?.onData?.({
type: 'session.event',
sessionId: 'session-1',
timestamp: new Date().toISOString(),
activities: [
{
source: 'agent-loop',
type: 'plan.updated',
runId: 'run-1',
step: 1,
timestamp: new Date().toISOString(),
explanation: 'Tracking current work.',
items: [
{ step: 'Inspect', status: 'completed' },
{ step: 'Implement', status: 'in_progress' },
],
},
],
} as ControlPlaneSessionEventEnvelope);

expect(store.getSnapshot().activePlan?.items).toEqual([
{ step: 'Inspect', status: 'completed' },
{ step: 'Implement', status: 'in_progress' },
]);

fixture.sessionEvents?.onData?.({
type: 'session.event',
sessionId: 'session-1',
timestamp: new Date().toISOString(),
activities: [
{
source: 'agent-loop',
type: 'loop.finished',
runId: 'run-1',
outcome: 'done',
summary: 'Done.',
timestamp: new Date().toISOString(),
},
],
} as ControlPlaneSessionEventEnvelope);

expect(store.getSnapshot().activePlan).toBeUndefined();
store.dispose();
});

it('keeps the final run outcome visible after loop completion', async () => {
const fixture = createClientFixture();
const store = new ControlPlaneSessionStore({ client: fixture.client });
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/unit/client-shared/session-activity-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ describe('ClientSharedSessionActivityService', () => {
expect(effects).toEqual(['finished:Run finished: done', 'workspace changed']);
});

it('applies plan update effects without changing live status', () => {
const effects: string[] = [];

ClientSharedSessionActivityService.applyActivity({
type: 'plan.updated',
runId: 'run-1',
source: 'agent-loop',
step: 1,
timestamp: new Date().toISOString(),
explanation: 'Tracking current work.',
items: [
{ step: 'Inspect', status: 'completed' },
{ step: 'Implement', status: 'in_progress' },
],
} as ControlPlaneSessionActivity, {
onPlanUpdated: (plan) => effects.push(plan.items[1]?.step ?? ''),
onLiveStatus: () => effects.push('live status changed'),
});

expect(effects).toEqual(['Implement']);
});

it('uses derived tool labels when the API provides them', () => {
expect(ClientSharedSessionActivityService.formatToolLabel({
type: 'tool.approval_requested',
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/unit/web-v2/agent-plan-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** @vitest-environment jsdom */

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AgentPlanPanel } from '../../../web-v2/components/conversation/AgentPlanPanel.js';
import type { ClientSharedSessionPlan } from '../../../client-shared/services/session-activities/index.js';

describe('AgentPlanPanel', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('renders the current plan summary and items', () => {
const plan = createPlan();

render(
<AgentPlanPanel plan={plan} />,
);

expect(screen.getByText('Plan')).toBeTruthy();
expect(screen.getAllByText('Implement plan UI')).toHaveLength(2);
expect(screen.getByText('Inspect current path')).toBeTruthy();
expect(screen.getByText('Verify behavior')).toBeTruthy();
expect(screen.getByText('Plan').closest('details')?.open).toBe(true);
});

it('defaults collapsed on mobile and can be expanded', () => {
mockMobileViewport();

render(<AgentPlanPanel plan={createPlan()} />);

const details = screen.getByText('Plan').closest('details');
expect(details?.open).toBe(false);

fireEvent.click(screen.getByText('Plan'));

expect(details?.open).toBe(true);
});
});

function createPlan(): ClientSharedSessionPlan {
return {
source: 'agent-loop',
type: 'plan.updated',
runId: 'run-1',
step: 1,
timestamp: new Date().toISOString(),
explanation: 'Tracking current work.',
items: [
{ step: 'Inspect current path', status: 'completed' },
{ step: 'Implement plan UI', status: 'in_progress' },
{ step: 'Verify behavior', status: 'pending' },
],
};
}

function mockMobileViewport(): void {
vi.stubGlobal('matchMedia', vi.fn((query) => ({
matches: query === '(max-width: 38rem)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
}
2 changes: 2 additions & 0 deletions src/cli-v2/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Text } from 'ink';
import { ApprovalPanel } from './components/ApprovalPanel.js';
import { AgentPlanPanel } from './components/AgentPlanPanel.js';
import { CommandResultPanel } from './components/CommandResultPanel.js';
import { ConversationPanel } from './components/ConversationPanel.js';
import { ModelPickerPanel } from './components/ModelPickerPanel.js';
Expand Down Expand Up @@ -118,6 +119,7 @@ export function App({
cancelling={snapshot.cancelling}
onCancel={cancelRun}
/>
<AgentPlanPanel plan={snapshot.activePlan} />
{pickers.model.query !== undefined ? (
<ModelPickerPanel
query={pickers.model.query}
Expand Down
31 changes: 31 additions & 0 deletions src/cli-v2/components/AgentPlanPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities/index.js';

const statusGlyphs = {
pending: '○',
in_progress: '●',
completed: '✓',
} satisfies Record<ClientSharedSessionPlan['items'][number]['status'], string>;

type AgentPlanPanelProps = {
plan?: ClientSharedSessionPlan;
};

export function AgentPlanPanel({ plan }: AgentPlanPanelProps) {
if (!plan) {
return null;
}

return (
<Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1} marginTop={1}>
<Text bold>Plan</Text>
{plan.explanation ? <Text color="gray">{plan.explanation}</Text> : null}
{plan.items.map((item) => (
<Text key={`${item.status}:${item.step}`} color={item.status === 'in_progress' ? 'cyan' : undefined}>
{statusGlyphs[item.status]} {item.step}
</Text>
))}
</Box>
);
}
15 changes: 15 additions & 0 deletions src/cli-v2/state/control-plane-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ControlPlaneProxyClient } from '@/client-shared/api/proxy.js';
import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities/index.js';
import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities/index.js';
import { ClientSharedSessionMessageService } from '@/client-shared/services/session-messages/index.js';
import {
SessionActivityService,
Expand Down Expand Up @@ -65,6 +66,7 @@ export type ControlPlaneSessionStoreSnapshot = {
cancelling: boolean;
streamConnected: boolean;
liveStatus?: string;
activePlan?: ClientSharedSessionPlan;
latestUpdate?: ControlPlaneSessionLatestUpdate;
slashCommandCatalog?: ControlPlaneSlashCommandCatalog;
commandResults: ControlPlaneSlashCommandResult[];
Expand Down Expand Up @@ -92,6 +94,8 @@ const INITIAL_SNAPSHOT: ControlPlaneSessionStoreSnapshot = {
* This is the non-React counterpart to web-v2's focused session hooks: it loads
* the selected workspace/session, subscribes to live updates, keeps transient
* conversation messages coherent, and exposes terminal intent methods.
* Shared activity policy stays in client-shared; this store owns only cli-v2
* state mutation and terminal workflow coordination.
*/
export class ControlPlaneSessionStore {
private readonly api: ControlPlaneSessionApiService;
Expand Down Expand Up @@ -192,6 +196,7 @@ export class ControlPlaneSessionStore {
runtimeContext: undefined,
pendingApproval: null,
liveStatus: undefined,
activePlan: undefined,
latestUpdate: undefined,
error: undefined,
loading: true,
Expand Down Expand Up @@ -243,6 +248,7 @@ export class ControlPlaneSessionStore {
submitting: true,
running: true,
error: undefined,
activePlan: undefined,
liveStatus: current.streamConnected
? 'Heddle is working...'
: 'Heddle is working... reconnecting live stream if needed.',
Expand Down Expand Up @@ -487,6 +493,7 @@ export class ControlPlaneSessionStore {
private async continueSession(workspaceId: string, sessionId: string): Promise<void> {
this.setSnapshot({
running: true,
activePlan: undefined,
liveStatus: 'Heddle is continuing from the current transcript...',
});
await this.api.continueSession(workspaceId, sessionId);
Expand All @@ -500,6 +507,7 @@ export class ControlPlaneSessionStore {
await this.api.sendPromptAsync({ workspaceId, sessionId, prompt });
this.setSnapshot({
running: true,
activePlan: undefined,
liveStatus: this.snapshotValue.streamConnected
? 'Heddle is working...'
: 'Heddle is working... reconnecting live stream if needed.',
Expand Down Expand Up @@ -567,6 +575,12 @@ export class ControlPlaneSessionStore {
onPendingApprovalChanged: () => {
void this.refreshPendingApproval(event.sessionId);
},
onPlanUpdated: (plan) => {
this.setSnapshot({ activePlan: plan });
},
onPlanCleared: () => {
this.setSnapshot({ activePlan: undefined });
},
onLiveStatus: (statusActivity, liveStatus) => {
const latestUpdate = SessionActivityService.resolveLatestUpdate(statusActivity);
if (liveStatus === undefined && latestUpdate === undefined) {
Expand Down Expand Up @@ -683,6 +697,7 @@ export class ControlPlaneSessionStore {
this.setSnapshot({
submitting: false,
liveStatus: undefined,
activePlan: undefined,
latestUpdate: {
label: 'Run finished',
tone: 'success',
Expand Down
29 changes: 29 additions & 0 deletions src/client-shared/services/session-activities/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Session Activity Client Effects

This service owns frontend-neutral effects derived from control-plane session
activities. It is the shared interpretation layer used by web-v2 and cli-v2
after they receive API-provided live events.

## Owns

- Mapping each API activity type to shared client effects.
- Shared live status copy for activity progress.
- Shared derived labels for tool-related activities.
- Active plan lifetime at the client edge: `plan.updated` sets the visible plan,
`loop.started` and `loop.finished` clear it.

## Does Not Own

- Activity facts or schemas. Those belong to `src/core/live` and flow through
tRPC-derived client types.
- Plan parsing or validation. That belongs to the core agent planning/tool
modules.
- React state, Ink state, layout, or rendering.
- Control-plane transport or subscription setup.

## Boundary

Do not add duplicated activity switchboards in web-v2 or cli-v2. Add a shared
effect here when both clients need to react to the same control-plane activity.
Client code should provide state setters and render the resulting state in its
own UI language.
1 change: 1 addition & 0 deletions src/client-shared/services/session-activities/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
ClientSharedSessionActivityService,
type ClientSharedSessionActivity,
type ClientSharedSessionPlan,
} from './session-activity-service.js';
Loading
Loading