feat(orchestration): receive typed v2 harness-session stream (#4652)

This commit is contained in:
oxoxDev
2026-07-07 23:39:27 -07:00
committed by GitHub
parent 63163ea1be
commit 2dccaf663f
35 changed files with 2271 additions and 156 deletions
@@ -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}