mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(orchestration): receive typed v2 harness-session stream (#4652)
This commit is contained in:
@@ -16,6 +16,19 @@ const approval: AttentionItem = {
|
||||
createdAt: '2026-07-06T10:00:00Z',
|
||||
};
|
||||
|
||||
// A REMOTE agent session parked on an approval: same `approval` kind as a local
|
||||
// gate approval, but its action opens the orchestration chat window instead of
|
||||
// the local approval surface.
|
||||
const remoteApproval: AttentionItem = {
|
||||
id: 'remote-approval:h-remote',
|
||||
kind: 'approval',
|
||||
instanceId: 'h-remote',
|
||||
title: 'Claude · deploy',
|
||||
summary: 'approve `rm -rf build/`',
|
||||
action: { type: 'open-session', sessionId: 'h-remote' },
|
||||
createdAt: '2026-07-06T12:00:00Z',
|
||||
};
|
||||
|
||||
const needsInput: AttentionItem = {
|
||||
id: 'needs-input:run-1',
|
||||
kind: 'needs-input',
|
||||
@@ -50,6 +63,21 @@ describe('AttentionQueueItem', () => {
|
||||
expect(onAction).toHaveBeenCalledWith({ type: 'approval', requestId: 'req-1' });
|
||||
});
|
||||
|
||||
it('renders a remote approval (approval kind) whose action opens its session', () => {
|
||||
const onAction = vi.fn();
|
||||
render(<AttentionQueueItem item={remoteApproval} onAction={onAction} />);
|
||||
const row = screen.getByTestId('attention-item-remote-approval:h-remote');
|
||||
expect(row).toHaveAttribute('data-kind', 'approval');
|
||||
// Approval kind → the amber "Review" verb, and the summary is shown.
|
||||
expect(screen.getByText('Claude · deploy')).toBeInTheDocument();
|
||||
expect(screen.getByText('approve `rm -rf build/`')).toBeInTheDocument();
|
||||
const action = screen.getByTestId('attention-item-action');
|
||||
expect(action).toHaveTextContent('tinyplaceOrchestration.attention.review');
|
||||
// Clicking routes the open-session action so the tab opens the chat window.
|
||||
fireEvent.click(action);
|
||||
expect(onAction).toHaveBeenCalledWith({ type: 'open-session', sessionId: 'h-remote' });
|
||||
});
|
||||
|
||||
it('renders a needs-input row with the Open action carrying its thread', () => {
|
||||
const onAction = vi.fn();
|
||||
render(<AttentionQueueItem item={needsInput} onAction={onAction} />);
|
||||
|
||||
@@ -74,4 +74,43 @@ describe('MessageBubble', () => {
|
||||
render(<MessageBubble message={message({ encrypted: true, body: '••••' })} />);
|
||||
expect(screen.getByText('••••')).toHaveClass('text-content-muted');
|
||||
});
|
||||
|
||||
it('renders a tool_call as a monospace command row with ▶ + tool name', () => {
|
||||
const { container } = render(
|
||||
<MessageBubble message={message({ eventKind: 'tool_call', toolName: 'Bash', body: 'ls' })} />
|
||||
);
|
||||
expect(container.querySelector('[data-event-kind="tool_call"]')).not.toBeNull();
|
||||
expect(screen.getByText('▶')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bash')).toBeInTheDocument();
|
||||
expect(container.querySelector('p.font-mono')?.textContent).toBe('ls');
|
||||
});
|
||||
|
||||
it('renders a tool_result with the ↳ glyph', () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={message({ eventKind: 'tool_result', toolName: 'Bash', body: 'ok' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('↳')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders agent_thinking italic + muted with the ∴ glyph', () => {
|
||||
const { container } = render(
|
||||
<MessageBubble message={message({ eventKind: 'agent_thinking', body: 'considering…' })} />
|
||||
);
|
||||
expect(container.querySelector('p.italic')).not.toBeNull();
|
||||
expect(screen.getByText('∴')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an error row with the ✕ glyph', () => {
|
||||
render(<MessageBubble message={message({ eventKind: 'error', body: 'boom' })} />);
|
||||
expect(screen.getByText('✕')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the plain-dot style for a legacy v1 row (no eventKind)', () => {
|
||||
const { container } = render(<MessageBubble message={message({ body: 'plain' })} />);
|
||||
expect(container.querySelector('[data-event-kind="v1"]')).not.toBeNull();
|
||||
expect(container.querySelector('div.rounded-full')).not.toBeNull();
|
||||
expect(container.querySelector('p.font-mono')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,19 +77,97 @@ export function ChatListButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kind presentation for a harness (v2) event row. Differentiates the typed
|
||||
* stream (tool_call / tool_result / agent_thinking / approval_request / error)
|
||||
* from plain agent/user messages so the orchestration thread reads like a live
|
||||
* activity log rather than undifferentiated text. Legacy v1 rows (no
|
||||
* `eventKind`) fall through to the default agent-message style.
|
||||
*/
|
||||
interface BubbleStyle {
|
||||
/** Leading marker: a glyph when the kind has one, else a colored dot. */
|
||||
dot: string;
|
||||
glyph?: string;
|
||||
/** Render the body in a monospace block (tool command / output). */
|
||||
mono?: boolean;
|
||||
/** Body text tone. */
|
||||
tone: string;
|
||||
/** Optional left-accent on the bubble. */
|
||||
accent: string;
|
||||
}
|
||||
|
||||
function bubbleStyle(kind: ChatMessage['eventKind']): BubbleStyle {
|
||||
switch (kind) {
|
||||
case 'tool_call':
|
||||
return {
|
||||
dot: 'text-ocean-500',
|
||||
glyph: '▶',
|
||||
mono: true,
|
||||
tone: 'text-content',
|
||||
accent: 'border-l-2 border-l-ocean-400',
|
||||
};
|
||||
case 'tool_result':
|
||||
return {
|
||||
dot: 'text-sage-500',
|
||||
glyph: '↳',
|
||||
mono: true,
|
||||
tone: 'text-content-muted',
|
||||
accent: 'border-l-2 border-l-sage-400',
|
||||
};
|
||||
case 'agent_thinking':
|
||||
return {
|
||||
dot: 'text-content-faint',
|
||||
glyph: '∴',
|
||||
tone: 'italic text-content-faint',
|
||||
accent: '',
|
||||
};
|
||||
case 'approval_request':
|
||||
return {
|
||||
dot: 'text-amber-500',
|
||||
glyph: '⚠',
|
||||
tone: 'text-content',
|
||||
accent: 'border-l-2 border-l-amber-400',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
dot: 'text-coral-500',
|
||||
glyph: '✕',
|
||||
tone: 'text-coral-600 dark:text-coral-300',
|
||||
accent: 'border-l-2 border-l-coral-400',
|
||||
};
|
||||
case 'user_prompt':
|
||||
return { dot: 'text-sage-500', tone: 'text-content', accent: '' };
|
||||
default:
|
||||
return { dot: 'text-ocean-500', tone: 'text-content', accent: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: ChatMessage }): ReactElement {
|
||||
const style = bubbleStyle(message.eventKind);
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mt-1.5 h-2 w-2 flex-none rounded-full bg-ocean-500" />
|
||||
<div className="min-w-0 rounded-lg border border-line bg-surface px-3 py-2 shadow-soft">
|
||||
<div className="flex gap-2" data-event-kind={message.eventKind ?? 'v1'}>
|
||||
{style.glyph ? (
|
||||
<span className={`mt-0.5 flex-none text-xs font-semibold ${style.dot}`}>{style.glyph}</span>
|
||||
) : (
|
||||
<div
|
||||
className={`mt-1.5 h-2 w-2 flex-none rounded-full ${style.dot.replace('text-', 'bg-')}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`min-w-0 rounded-lg border border-line bg-surface px-3 py-2 shadow-soft ${style.accent}`}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-xs font-semibold text-content-secondary">{message.from}</span>
|
||||
{message.toolName ? (
|
||||
<span className="rounded bg-surface-strong px-1.5 py-0.5 font-mono text-[10px] text-content-secondary">
|
||||
{message.toolName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[10px] text-content-faint">{formatTime(message.timestamp)}</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
|
||||
message.encrypted ? 'text-content-muted' : 'text-content'
|
||||
}`}>
|
||||
className={`mt-1 max-h-64 overflow-y-auto whitespace-pre-wrap break-words ${
|
||||
style.mono ? 'font-mono text-xs' : 'text-sm'
|
||||
} ${message.encrypted ? 'text-content-muted' : style.tone}`}>
|
||||
{message.body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,9 @@ const props = (over: Partial<OrchestrationSidebarProps>): OrchestrationSidebarPr
|
||||
steeringText: null,
|
||||
selfIdentity: null,
|
||||
identityLoading: false,
|
||||
onPublishIdentity: vi.fn(),
|
||||
publishingIdentity: false,
|
||||
publishIdentityError: null,
|
||||
attentionQueue: null,
|
||||
attentionLoading: false,
|
||||
onAttentionAction: vi.fn(),
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface OrchestrationSidebarProps {
|
||||
steeringText: string | null;
|
||||
selfIdentity: SelfIdentity | null;
|
||||
identityLoading: boolean;
|
||||
onPublishIdentity: () => void;
|
||||
publishingIdentity: boolean;
|
||||
publishIdentityError: string | null;
|
||||
attentionQueue: AttentionQueue | null;
|
||||
attentionLoading: boolean;
|
||||
onAttentionAction: (action: AttentionAction) => void;
|
||||
@@ -72,6 +75,9 @@ export default function OrchestrationSidebar({
|
||||
steeringText,
|
||||
selfIdentity,
|
||||
identityLoading,
|
||||
onPublishIdentity,
|
||||
publishingIdentity,
|
||||
publishIdentityError,
|
||||
attentionQueue,
|
||||
attentionLoading,
|
||||
onAttentionAction,
|
||||
@@ -146,7 +152,13 @@ export default function OrchestrationSidebar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SelfIdentityCard identity={selfIdentity} loading={identityLoading} />
|
||||
<SelfIdentityCard
|
||||
identity={selfIdentity}
|
||||
loading={identityLoading}
|
||||
onPublish={onPublishIdentity}
|
||||
publishing={publishingIdentity}
|
||||
publishError={publishIdentityError}
|
||||
/>
|
||||
|
||||
<AttentionQueueView
|
||||
queue={attentionQueue}
|
||||
|
||||
@@ -60,6 +60,66 @@ describe('SelfIdentityCard', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const undiscoverable: SelfIdentity = {
|
||||
agentId: 'addrWithNoCardPublishedYet',
|
||||
handles: [{ username: 'openhuman', primary: true }],
|
||||
primaryHandle: 'openhuman',
|
||||
cardPublished: false,
|
||||
keyPublished: false,
|
||||
discoverable: false,
|
||||
};
|
||||
|
||||
it('renders a "Make discoverable" button that fires onPublish while undiscoverable', () => {
|
||||
const onPublish = vi.fn();
|
||||
render(<SelfIdentityCard identity={undiscoverable} loading={false} onPublish={onPublish} />);
|
||||
const btn = screen.getByTestId('tinyplace-self-identity-publish');
|
||||
expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.makeDiscoverable');
|
||||
fireEvent.click(btn);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables the button and shows the publishing label while a publish is in flight', () => {
|
||||
render(
|
||||
<SelfIdentityCard identity={undiscoverable} loading={false} onPublish={vi.fn()} publishing />
|
||||
);
|
||||
const btn = screen.getByTestId('tinyplace-self-identity-publish');
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.publishing');
|
||||
});
|
||||
|
||||
it('surfaces a publish error under the button', () => {
|
||||
render(
|
||||
<SelfIdentityCard
|
||||
identity={undiscoverable}
|
||||
loading={false}
|
||||
onPublish={vi.fn()}
|
||||
publishError="boom"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('tinyplace-self-identity-publish-error')).toHaveTextContent(
|
||||
'tinyplaceOrchestration.identity.publishFailed'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows no "make discoverable" button once discoverable', () => {
|
||||
render(<SelfIdentityCard identity={discoverable} loading={false} onPublish={vi.fn()} />);
|
||||
expect(screen.queryByTestId('tinyplace-self-identity-publish')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a "Republish keys" action while discoverable that fires onPublish', () => {
|
||||
const onPublish = vi.fn();
|
||||
render(<SelfIdentityCard identity={discoverable} loading={false} onPublish={onPublish} />);
|
||||
const btn = screen.getByTestId('tinyplace-self-identity-republish');
|
||||
expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.republish');
|
||||
fireEvent.click(btn);
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits the publish button when no onPublish handler is provided', () => {
|
||||
render(<SelfIdentityCard identity={undiscoverable} loading={false} />);
|
||||
expect(screen.queryByTestId('tinyplace-self-identity-publish')).toBeNull();
|
||||
});
|
||||
|
||||
it('copies the address to the clipboard on click', async () => {
|
||||
render(<SelfIdentityCard identity={discoverable} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId('tinyplace-self-identity-copy'));
|
||||
|
||||
@@ -20,6 +20,16 @@ import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient';
|
||||
export interface SelfIdentityCardProps {
|
||||
identity: SelfIdentity | null;
|
||||
loading: boolean;
|
||||
/**
|
||||
* Publish (or refresh) this agent's directory card + Signal key so peers can
|
||||
* DM it. Rendered only while undiscoverable. The parent owns the RPC + the
|
||||
* identity refresh; the card just drives the button state.
|
||||
*/
|
||||
onPublish?: () => void;
|
||||
/** True while {@link onPublish} is in flight — disables the button. */
|
||||
publishing?: boolean;
|
||||
/** Non-null when the last publish attempt failed — surfaces under the button. */
|
||||
publishError?: string | null;
|
||||
}
|
||||
|
||||
function shortAddress(address: string): string {
|
||||
@@ -30,6 +40,9 @@ function shortAddress(address: string): string {
|
||||
export default function SelfIdentityCard({
|
||||
identity,
|
||||
loading,
|
||||
onPublish,
|
||||
publishing,
|
||||
publishError,
|
||||
}: SelfIdentityCardProps): React.ReactElement {
|
||||
const { t } = useT();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -124,10 +137,53 @@ export default function SelfIdentityCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{identity.discoverable && onPublish ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tinyplace-self-identity-republish"
|
||||
onClick={onPublish}
|
||||
disabled={publishing}
|
||||
className="inline-flex items-center rounded-md border border-line px-2 py-0.5 text-[10px] font-medium text-content-muted transition hover:bg-surface-hover disabled:opacity-50">
|
||||
{publishing
|
||||
? t('tinyplaceOrchestration.identity.publishing')
|
||||
: t('tinyplaceOrchestration.identity.republish')}
|
||||
</button>
|
||||
{publishError ? (
|
||||
<span
|
||||
data-testid="tinyplace-self-identity-republish-error"
|
||||
className="text-[10px] text-coral-600 dark:text-coral-300">
|
||||
{t('tinyplaceOrchestration.identity.publishFailed')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!identity.discoverable ? (
|
||||
<p className="mt-2 rounded-md bg-coral-50 px-2 py-1 text-[10px] text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('tinyplaceOrchestration.identity.undiscoverableHint')}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md bg-coral-50 px-2 py-1.5 dark:bg-coral-500/10">
|
||||
<p className="text-[10px] text-coral-700 dark:text-coral-300">
|
||||
{t('tinyplaceOrchestration.identity.undiscoverableHint')}
|
||||
</p>
|
||||
{onPublish ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tinyplace-self-identity-publish"
|
||||
onClick={onPublish}
|
||||
disabled={publishing}
|
||||
className="mt-1.5 inline-flex items-center rounded-md bg-coral-600 px-2 py-1 text-[11px] font-semibold text-white transition hover:bg-coral-700 disabled:opacity-50 dark:bg-coral-500 dark:hover:bg-coral-400">
|
||||
{publishing
|
||||
? t('tinyplaceOrchestration.identity.publishing')
|
||||
: t('tinyplaceOrchestration.identity.makeDiscoverable')}
|
||||
</button>
|
||||
) : null}
|
||||
{publishError ? (
|
||||
<p
|
||||
data-testid="tinyplace-self-identity-publish-error"
|
||||
className="mt-1 text-[10px] text-coral-700 dark:text-coral-300">
|
||||
{t('tinyplaceOrchestration.identity.publishFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => {
|
||||
markRead: vi.fn(),
|
||||
status: vi.fn(),
|
||||
selfIdentity: vi.fn(),
|
||||
publishIdentity: vi.fn(),
|
||||
relayInfo: vi.fn(),
|
||||
attention: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -69,6 +69,10 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
// the whole tab.
|
||||
const [selfIdentity, setSelfIdentity] = useState<SelfIdentity | null>(null);
|
||||
const [identityLoading, setIdentityLoading] = useState(true);
|
||||
// "Make discoverable" remediation: publishes the directory card + Signal key
|
||||
// when the identity card reports the agent is un-messageable.
|
||||
const [publishingIdentity, setPublishingIdentity] = useState(false);
|
||||
const [publishIdentityError, setPublishIdentityError] = useState<string | null>(null);
|
||||
const [relayInfo, setRelayInfo] = useState<RelayInfo | null>(null);
|
||||
// The aggregated "needs you" queue (approvals + blocked runs + unread). Read
|
||||
// independently of chats so a failure leaves the zone empty, never the tab.
|
||||
@@ -152,6 +156,28 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
setIdentityLoading(false);
|
||||
}, []);
|
||||
|
||||
// Publish (or refresh) the directory card + Signal key, then adopt the fresh
|
||||
// identity the RPC echoes back so the card flips to "discoverable" without a
|
||||
// separate reload. A failure surfaces on the card, not the whole tab.
|
||||
const publishIdentity = useCallback(async () => {
|
||||
debug('[tinyplace-orchestration] publish identity entry');
|
||||
setPublishingIdentity(true);
|
||||
setPublishIdentityError(null);
|
||||
try {
|
||||
const identity = await orchestrationClient.publishIdentity();
|
||||
if (!mountedRef.current) return;
|
||||
debug('[tinyplace-orchestration] publish identity ok discoverable=%s', identity.discoverable);
|
||||
setSelfIdentity(identity);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debug('[tinyplace-orchestration] publish identity error %s', message);
|
||||
setPublishIdentityError(message);
|
||||
} finally {
|
||||
if (mountedRef.current) setPublishingIdentity(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAttention = useCallback(async () => {
|
||||
debug('[tinyplace-orchestration] attention load entry');
|
||||
try {
|
||||
@@ -348,6 +374,9 @@ export default function TinyPlaceOrchestrationTab() {
|
||||
steeringText={steeringText}
|
||||
selfIdentity={selfIdentity}
|
||||
identityLoading={identityLoading}
|
||||
onPublishIdentity={publishIdentity}
|
||||
publishingIdentity={publishingIdentity}
|
||||
publishIdentityError={publishIdentityError}
|
||||
attentionQueue={attentionQueue}
|
||||
attentionLoading={attentionLoading}
|
||||
onAttentionAction={handleAttentionAction}
|
||||
|
||||
Reference in New Issue
Block a user