feat(orchestration): promote TinyPlace Orchestration to its own tab + teach the orchestrator to manage a fleet (#4693)

This commit is contained in:
Steven Enamakel
2026-07-08 00:04:51 -07:00
committed by GitHub
parent 2dccaf663f
commit 078844323d
39 changed files with 2255 additions and 39 deletions
+18
View File
@@ -17,6 +17,7 @@ import FlowsPage from './pages/FlowsPage';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
import Onboarding from './pages/onboarding/Onboarding';
import OrchestrationPage from './pages/OrchestrationPage';
import { PttOverlayPage } from './pages/PttOverlayPage';
import Rewards from './pages/Rewards';
import Skills from './pages/Skills';
@@ -134,6 +135,23 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
}
/>
{/* Orchestration — TinyPlace multi-agent coordination surface, promoted
from a Brain sub-tab into a first-class sidebar destination (sits
right after Workflows). */}
<Route
path="/orchestration"
element={
<ProtectedRoute requireAuth={true}>
<OrchestrationPage />
</ProtectedRoute>
}
/>
{/* Back-compat: the old Brain deep link → the promoted top-level tab. */}
<Route
path="/brain/tinyplace-orchestration"
element={<Navigate to="/orchestration" replace />}
/>
{/* Back-compat: /activity and /intelligence → settings notifications page. */}
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
@@ -29,6 +29,7 @@ describe('CollapsedNavRail', () => {
'nav.human',
'nav.brain',
'nav.flows',
'nav.orchestration',
'nav.agentWorld',
'nav.connections',
]) {
@@ -118,6 +118,23 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
</svg>
);
case 'orchestration':
// Conductor/hub glyph — one root node branching to two workers,
// representing multi-agent orchestration (distinct from the horizontal
// `flows` automation graph).
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" strokeWidth={1.8} />
<circle cx="5" cy="19" r="2" strokeWidth={1.8} />
<circle cx="19" cy="19" r="2" strokeWidth={1.8} />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 7v3m0 0l-5.5 6M12 10l5.5 6"
/>
</svg>
);
case 'agent-world':
// Globe/network glyph — represents the A2A agent social network.
return (
@@ -0,0 +1,132 @@
/**
* AgentChatPanel — "chat with the main agent and see its subconscious".
*
* The focused conversation surface of the Orchestration tab: a two-way toggle
* between the Master chat (you ↔ the main agent) and the Subconscious chat (the
* background steering loop), rendered through the shared {@link OrchestrationFocusPane}.
* Connections/discovery moved to their own sub-pages, so this panel deliberately
* drops the contact rail and keeps just the agent dialogue + steering controls.
*/
import debugFactory from 'debug';
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import {
MASTER_CHAT_KEY,
SUBCONSCIOUS_CHAT_KEY,
useOrchestrationChats,
} from '../../lib/orchestration/useOrchestrationChats';
import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious';
import OrchestrationFocusPane from '../intelligence/OrchestrationFocusPane';
const debug = debugFactory('orchestration:agent-chat');
export default function AgentChatPanel() {
const { t } = useT();
const {
sessionsState,
messagesState,
selectedId,
selected,
status,
masterError,
selectChat,
refresh,
sendMessage,
} = useOrchestrationChats(t);
const [composerBody, setComposerBody] = useState('');
const [sending, setSending] = useState(false);
const [runningReview, setRunningReview] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const submitComposer = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const body = composerBody.trim();
if (!body || sending) return;
setSending(true);
void sendMessage(selected, body).then(ok => {
if (!mountedRef.current) return;
if (ok) setComposerBody('');
setSending(false);
});
},
[composerBody, sending, sendMessage, selected]
);
const runSteeringReview = useCallback(async () => {
debug('steering review: trigger');
setRunningReview(true);
try {
await subconsciousTrigger('tinyplace');
} catch (err) {
debug('steering review trigger failed: %o', err);
} finally {
setRunningReview(false);
}
}, []);
const steeringText = status?.steering?.text?.trim() || null;
const isMasterSelected = selectedId === MASTER_CHAT_KEY;
const tabs: Array<{ key: string; label: string }> = [
{ key: MASTER_CHAT_KEY, label: t('orchPage.agent.mainTab') },
{ key: SUBCONSCIOUS_CHAT_KEY, label: t('orchPage.agent.subconsciousTab') },
];
return (
<div className="flex h-full min-h-[520px] flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
{/* Master ↔ Subconscious switch. */}
<div
className="flex shrink-0 items-center gap-1 border-b border-line px-3 py-2"
role="tablist"
aria-label={t('orchPage.agent.description')}>
{tabs.map(tab => {
const active = selectedId === tab.key;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
data-testid={`orch-agent-tab-${tab.key}`}
onClick={() => selectChat(tab.key)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
active
? 'bg-surface-subtle font-medium text-content'
: 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}>
{tab.label}
</button>
);
})}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<OrchestrationFocusPane
selected={selected}
sessionsState={sessionsState}
messagesState={messagesState}
status={status}
masterError={masterError}
refresh={refresh}
steeringText={steeringText}
runningReview={runningReview}
onRunSteeringReview={() => void runSteeringReview()}
canCompose={isMasterSelected}
composerBody={composerBody}
onComposerChange={setComposerBody}
sending={sending}
onSubmitComposer={submitComposer}
/>
</div>
</div>
);
}
@@ -0,0 +1,168 @@
/**
* ConnectionsPanel — "manage connections".
*
* Lists the agent's accepted tiny.place contacts (the peers the main agent can
* coordinate with), with a per-contact block action and a count summary. Adding
* new links + accepting requests lives in the sibling {@link DiscoverPanel};
* this panel is the steady-state management view.
*/
import { useEffect, useMemo, useState } from 'react';
import { apiClient } from '../../agentworld/AgentWorldShell';
import { useT } from '../../lib/i18n/I18nContext';
import { usePairing } from '../../lib/orchestration/usePairing';
import { contactAddress, extractHandle } from '../intelligence/orchestrationTabHelpers';
import Button from '../ui/Button';
import { SectionCard, StatTile } from './primitives';
export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => void }) {
const { t } = useT();
const { state, runAction, pendingAction, actionError } = usePairing();
const [handles, setHandles] = useState<Record<string, string | null>>({});
const snapshot = state.status === 'ok' ? state.snapshot : null;
const accepted = useMemo(
() => (snapshot?.contacts.contacts ?? []).filter(c => c.status === 'accepted'),
[snapshot?.contacts.contacts]
);
const stats = snapshot?.stats ?? null;
// Best-effort @handle resolution for the listed contacts (address always shown).
const addressesKey = accepted.map(contactAddress).filter(Boolean).join(',');
useEffect(() => {
const ids = addressesKey ? Array.from(new Set(addressesKey.split(','))) : [];
if (ids.length === 0) return;
let cancelled = false;
void Promise.all(
ids.map(async id => {
try {
return [id, extractHandle(await apiClient.directory.reverse(id))] as const;
} catch {
return [id, null] as const;
}
})
).then(entries => {
if (cancelled) return;
setHandles(prev => {
const next = { ...prev };
for (const [id, handle] of entries) if (!(id in next)) next[id] = handle;
return next;
});
});
return () => {
cancelled = true;
};
}, [addressesKey]);
if (state.status === 'loading') {
return (
<p
className="py-8 text-center text-sm text-content-muted"
data-testid="orch-connections-loading">
{t('tinyplaceOrchestration.loading')}
</p>
);
}
if (state.status === 'payment_required') {
return (
<p className="py-8 text-center text-sm text-amber-600 dark:text-amber-300">
{t('tinyplaceOrchestration.paymentRequired')}
</p>
);
}
if (state.status === 'error') {
return (
<p className="py-8 text-center text-sm text-coral-600 dark:text-coral-300">
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
</p>
);
}
return (
<div className="space-y-5" data-testid="orch-connections-panel">
<div className="grid grid-cols-2 gap-3">
<StatTile
label={t('orchPage.connections.statContacts')}
value={stats?.contactCount ?? accepted.length}
testId="orch-connections-stat-contacts"
/>
<StatTile
label={t('orchPage.connections.statPending')}
value={(stats?.pendingIncoming ?? 0) + (stats?.pendingOutgoing ?? 0)}
hint={t('orchPage.connections.pendingHint')}
testId="orch-connections-stat-pending"
/>
</div>
{actionError && (
<p className="rounded-md bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{actionError}
</p>
)}
<SectionCard
title={t('orchPage.connections.title')}
description={t('orchPage.connections.description')}
action={
onDiscover && (
<Button
variant="secondary"
size="sm"
onClick={onDiscover}
data-testid="orch-connections-add">
{t('orchPage.discover.linkAction')}
</Button>
)
}>
{accepted.length === 0 ? (
<div className="py-6 text-center" data-testid="orch-connections-empty">
<p className="text-sm text-content-muted">{t('orchPage.connections.empty')}</p>
{onDiscover && (
<Button
variant="primary"
size="sm"
className="mt-3"
onClick={onDiscover}
data-testid="orch-connections-empty-cta">
{t('orchPage.connections.emptyCta')}
</Button>
)}
</div>
) : (
<ul className="divide-y divide-line">
{accepted.map(contact => {
const address = contactAddress(contact);
const handle = handles[address];
const actionId = `block:${address}`;
return (
<li
key={address}
className="flex items-center justify-between gap-3 py-2.5"
data-testid="orch-connection-row">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
</p>
<p className="truncate font-mono text-[11px] text-content-faint">{address}</p>
</div>
<Button
variant="tertiary"
size="sm"
disabled={pendingAction === actionId}
onClick={() =>
void runAction(actionId, () =>
apiClient.orchestrationPairing.blockRequest(address)
)
}
data-testid="orch-connection-block">
{t('tinyplaceOrchestration.pairing.block')}
</Button>
</li>
);
})}
</ul>
)}
</SectionCard>
</div>
);
}
@@ -0,0 +1,228 @@
/**
* DiscoverPanel — "handle connections and guide the user to add more".
*
* The growth surface: shows this agent's own tiny.place discoverability (so the
* user knows whether peers can even reach them), a link-a-new-agent form, and
* the inbound contact-request queue (accept / decline / block). Steady-state
* management of already-linked peers lives in the sibling {@link ConnectionsPanel}.
*/
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { apiClient } from '../../agentworld/AgentWorldShell';
import { useT } from '../../lib/i18n/I18nContext';
import {
orchestrationClient,
type RelayInfo,
type SelfIdentity,
} from '../../lib/orchestration/orchestrationClient';
import { usePairing } from '../../lib/orchestration/usePairing';
import { contactAddress, extractHandle } from '../intelligence/orchestrationTabHelpers';
import Button from '../ui/Button';
import { SectionCard } from './primitives';
export default function DiscoverPanel() {
const { t } = useT();
const { state, runAction, pendingAction, actionError } = usePairing();
const [linkAgentId, setLinkAgentId] = useState('');
const [identity, setIdentity] = useState<SelfIdentity | null>(null);
const [relay, setRelay] = useState<RelayInfo | null>(null);
const [identityLoading, setIdentityLoading] = useState(true);
const [handles, setHandles] = useState<Record<string, string | null>>({});
useEffect(() => {
let cancelled = false;
void Promise.allSettled([
orchestrationClient.selfIdentity(),
orchestrationClient.relayInfo(),
]).then(([id, rel]) => {
if (cancelled) return;
if (id.status === 'fulfilled') setIdentity(id.value);
if (rel.status === 'fulfilled') setRelay(rel.value);
setIdentityLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const snapshot = state.status === 'ok' ? state.snapshot : null;
const incoming = useMemo(() => snapshot?.requests.incoming ?? [], [snapshot?.requests.incoming]);
// Resolve @handles for the inbound requests (best-effort; address always shown).
const addressesKey = incoming.map(contactAddress).filter(Boolean).join(',');
useEffect(() => {
const ids = addressesKey ? Array.from(new Set(addressesKey.split(','))) : [];
if (ids.length === 0) return;
let cancelled = false;
void Promise.all(
ids.map(async id => {
try {
return [id, extractHandle(await apiClient.directory.reverse(id))] as const;
} catch {
return [id, null] as const;
}
})
).then(entries => {
if (cancelled) return;
setHandles(prev => {
const next = { ...prev };
for (const [id, handle] of entries) if (!(id in next)) next[id] = handle;
return next;
});
});
return () => {
cancelled = true;
};
}, [addressesKey]);
const submitLink = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const agentId = linkAgentId.trim();
if (!agentId) return;
void runAction(`link:${agentId}`, async () => {
await apiClient.orchestrationPairing.linkSession(agentId);
setLinkAgentId('');
});
},
[linkAgentId, runAction]
);
const discoverable = identity?.discoverable ?? false;
return (
<div className="space-y-5" data-testid="orch-discover-panel">
{/* Own identity / discoverability. */}
<SectionCard title={t('orchPage.discover.identityTitle')} testId="orch-discover-identity">
{identityLoading ? (
<p className="text-sm text-content-muted">
{t('tinyplaceOrchestration.identity.loading')}
</p>
) : identity ? (
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
discoverable
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
}`}
data-testid="orch-discover-discoverable">
{discoverable
? t('tinyplaceOrchestration.identity.discoverable')
: t('tinyplaceOrchestration.identity.undiscoverable')}
</span>
{relay && (
<span className="text-xs text-content-faint">
{relay.network === 'prod'
? t('tinyplaceOrchestration.relay.prod')
: t('tinyplaceOrchestration.relay.staging')}
</span>
)}
</div>
<p className="text-content-secondary">
{identity.primaryHandle
? `@${identity.primaryHandle.replace(/^@+/, '')}`
: t('tinyplaceOrchestration.identity.noHandle')}
</p>
{!discoverable && (
<p className="text-xs text-content-muted" data-testid="orch-discover-guide">
{t('orchPage.discover.notDiscoverableGuide')}
</p>
)}
</div>
) : (
<p className="text-sm text-content-muted">
{t('tinyplaceOrchestration.identity.noHandle')}
</p>
)}
</SectionCard>
{/* Link a new agent. */}
<SectionCard
title={t('orchPage.discover.linkTitle')}
description={t('orchPage.discover.linkDescription')}
testId="orch-discover-link">
<form className="flex gap-2" onSubmit={submitLink}>
<input
value={linkAgentId}
onChange={e => setLinkAgentId(e.target.value)}
placeholder={t('tinyplaceOrchestration.pairing.linkPlaceholder')}
className="min-w-0 flex-1 rounded-md border border-line bg-surface px-3 py-2 text-sm text-content outline-none transition focus:border-ocean-500 focus:ring-2 focus:ring-ocean-500/20"
data-testid="orch-discover-link-input"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={!linkAgentId.trim() || pendingAction === `link:${linkAgentId.trim()}`}
data-testid="orch-discover-link-submit">
{t('tinyplaceOrchestration.pairing.linkAction')}
</Button>
</form>
{actionError && (
<p className="mt-2 text-xs text-coral-600 dark:text-coral-300">{actionError}</p>
)}
</SectionCard>
{/* Inbound requests. */}
<SectionCard
title={t('tinyplaceOrchestration.pairing.requests')}
testId="orch-discover-requests">
{state.status === 'loading' ? (
<p className="text-sm text-content-muted">{t('tinyplaceOrchestration.loading')}</p>
) : incoming.length === 0 ? (
<p className="py-2 text-sm text-content-muted" data-testid="orch-discover-no-requests">
{t('orchPage.discover.noRequests')}
</p>
) : (
<ul className="divide-y divide-line">
{incoming.map(request => {
const address = contactAddress(request);
const handle = handles[address];
return (
<li
key={address}
className="flex items-center justify-between gap-3 py-2.5"
data-testid="orch-discover-request-row">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-content">
{handle ? `@${handle}` : t('tinyplaceOrchestration.unknownSender')}
</p>
<p className="truncate font-mono text-[11px] text-content-faint">{address}</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Button
variant="primary"
size="sm"
disabled={pendingAction === `accept:${address}`}
onClick={() =>
void runAction(`accept:${address}`, () =>
apiClient.orchestrationPairing.acceptRequest(address)
)
}
data-testid="orch-discover-accept">
{t('tinyplaceOrchestration.pairing.accept')}
</Button>
<Button
variant="tertiary"
size="sm"
disabled={pendingAction === `decline:${address}`}
onClick={() =>
void runAction(`decline:${address}`, () =>
apiClient.orchestrationPairing.declineRequest(address)
)
}
data-testid="orch-discover-decline">
{t('tinyplaceOrchestration.pairing.decline')}
</Button>
</div>
</li>
);
})}
</ul>
)}
</SectionCard>
</div>
);
}
@@ -0,0 +1,140 @@
/**
* UsagePanel — "stats on how much tokens, connections etc. have been spent".
*
* A read-only dashboard of the orchestration surface's footprint: credit
* balance + cycle spend (billing), inference/integration call counts (team
* usage), TokenJuice compaction savings, and the live connection count. Every
* source is loaded independently (`Promise.allSettled`) so a single unavailable
* backend (e.g. billing offline in a local build) degrades one tile to "—"
* rather than blanking the page.
*/
import debugFactory from 'debug';
import { useEffect, useState } from 'react';
import { apiClient } from '../../agentworld/AgentWorldShell';
import { useT } from '../../lib/i18n/I18nContext';
import { type CreditBalance, creditsApi, type TeamUsage } from '../../services/api/creditsApi';
import { getTokenjuiceSavings, type SavingsStats } from '../../utils/tauriCommands/tokenjuice';
import { StatTile } from './primitives';
const debug = debugFactory('orchestration:usage');
function usd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return '—';
return `$${value.toFixed(2)}`;
}
function count(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return '—';
return new Intl.NumberFormat().format(value);
}
interface UsageData {
balance: CreditBalance | null;
team: TeamUsage | null;
savings: SavingsStats | null;
connections: number | null;
}
export default function UsagePanel() {
const { t } = useT();
const [data, setData] = useState<UsageData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
debug('usage load: entry');
void Promise.allSettled([
creditsApi.getBalance(),
creditsApi.getTeamUsage(),
getTokenjuiceSavings(),
apiClient.orchestrationPairing.list(),
]).then(([balance, team, savings, pairing]) => {
if (cancelled) return;
debug(
'usage load: exit balance=%s team=%s savings=%s pairing=%s',
balance.status,
team.status,
savings.status,
pairing.status
);
setData({
balance: balance.status === 'fulfilled' ? balance.value : null,
team: team.status === 'fulfilled' ? team.value : null,
savings: savings.status === 'fulfilled' ? savings.value : null,
connections:
pairing.status === 'fulfilled'
? (pairing.value.stats?.contactCount ??
pairing.value.contacts.contacts.filter(c => c.status === 'accepted').length)
: null,
});
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<p className="py-8 text-center text-sm text-content-muted" data-testid="orch-usage-loading">
{t('tinyplaceOrchestration.loading')}
</p>
);
}
const balanceUsd =
data?.balance != null
? data.balance.promotionBalanceUsd + data.balance.teamTopupUsd
: undefined;
const totals = data?.team?.insights.totals;
return (
<div className="space-y-3" data-testid="orch-usage-panel">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile
label={t('orchPage.usage.connections')}
value={count(data?.connections ?? undefined)}
testId="orch-usage-connections"
/>
<StatTile
label={t('orchPage.usage.balance')}
value={usd(balanceUsd)}
hint={t('orchPage.usage.balanceHint')}
testId="orch-usage-balance"
/>
<StatTile
label={t('orchPage.usage.cycleSpend')}
value={usd(data?.team?.cycleSpentUsd)}
hint={
data?.team
? `${t('orchPage.usage.ofBudget')} ${usd(data.team.cycleBudgetUsd)}`
: undefined
}
testId="orch-usage-cycle-spend"
/>
<StatTile
label={t('orchPage.usage.inferenceCalls')}
value={count(totals?.inferenceCalls)}
testId="orch-usage-inference-calls"
/>
<StatTile
label={t('orchPage.usage.integrationCalls')}
value={count(totals?.integrationCalls)}
testId="orch-usage-integration-calls"
/>
<StatTile
label={t('orchPage.usage.tokensSaved')}
value={count(data?.savings?.total.tokensSaved)}
hint={
data?.savings
? `${usd(data.savings.total.costSavedUsd)} ${t('orchPage.usage.saved')}`
: undefined
}
testId="orch-usage-tokens-saved"
/>
</div>
<p className="text-[11px] text-content-faint">{t('orchPage.usage.footnote')}</p>
</div>
);
}
@@ -0,0 +1,105 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AgentChatPanel from '../AgentChatPanel';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const selectChat = vi.hoisted(() => vi.fn());
const chatsApi = vi.hoisted(() => ({
current: {
sessionsState: { status: 'ok' },
messagesState: { status: 'ok' },
selectedId: 'master',
selected: { id: 'master', title: 'Master', messages: [] },
status: null,
masterError: null,
selectChat,
refresh: vi.fn(),
sendMessage: vi.fn().mockResolvedValue(true),
},
}));
vi.mock('../../../lib/orchestration/useOrchestrationChats', () => ({
MASTER_CHAT_KEY: 'master',
SUBCONSCIOUS_CHAT_KEY: 'subconscious',
useOrchestrationChats: () => chatsApi.current,
}));
// Surface the props we care about from the focus pane stub, and expose controls
// that invoke the container's composer + steering-review handlers.
interface FocusStubProps {
canCompose: boolean;
selected?: { id: string };
composerBody: string;
onComposerChange: (v: string) => void;
onSubmitComposer: (e: { preventDefault: () => void }) => void;
onRunSteeringReview: () => void;
}
vi.mock('../../intelligence/OrchestrationFocusPane', () => ({
default: ({
canCompose,
selected,
composerBody,
onComposerChange,
onSubmitComposer,
onRunSteeringReview,
}: FocusStubProps) => (
<div
data-testid="focus-pane"
data-can-compose={String(canCompose)}
data-selected={selected?.id}>
<form data-testid="focus-form" onSubmit={onSubmitComposer}>
<input
data-testid="focus-input"
value={composerBody}
onChange={e => onComposerChange(e.target.value)}
/>
</form>
<button data-testid="focus-steering" onClick={() => onRunSteeringReview()}>
review
</button>
</div>
),
}));
const subconsciousTrigger = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('../../../utils/tauriCommands/subconscious', () => ({ subconsciousTrigger }));
describe('AgentChatPanel', () => {
beforeEach(() => vi.clearAllMocks());
it('renders the master/subconscious toggle and the focus pane', () => {
render(<AgentChatPanel />);
expect(screen.getByTestId('orch-agent-tab-master')).toBeInTheDocument();
expect(screen.getByTestId('orch-agent-tab-subconscious')).toBeInTheDocument();
expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'true');
});
it('switches to the subconscious chat when its tab is clicked', () => {
render(<AgentChatPanel />);
fireEvent.click(screen.getByTestId('orch-agent-tab-subconscious'));
expect(selectChat).toHaveBeenCalledWith('subconscious');
});
it('disables composing when the subconscious chat is selected', () => {
chatsApi.current = { ...chatsApi.current, selectedId: 'subconscious', selectChat };
render(<AgentChatPanel />);
expect(screen.getByTestId('focus-pane')).toHaveAttribute('data-can-compose', 'false');
});
it('sends a composed message through the chats api', async () => {
const sendMessage = vi.fn().mockResolvedValue(true);
chatsApi.current = { ...chatsApi.current, selectedId: 'master', selectChat, sendMessage };
render(<AgentChatPanel />);
fireEvent.change(screen.getByTestId('focus-input'), { target: { value: 'hello' } });
fireEvent.submit(screen.getByTestId('focus-form'));
await waitFor(() => expect(sendMessage).toHaveBeenCalledWith(expect.anything(), 'hello'));
});
it('triggers a subconscious steering review', async () => {
chatsApi.current = { ...chatsApi.current, selectChat };
render(<AgentChatPanel />);
fireEvent.click(screen.getByTestId('focus-steering'));
await waitFor(() => expect(subconsciousTrigger).toHaveBeenCalledWith('tinyplace'));
});
});
@@ -0,0 +1,75 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ConnectionsPanel from '../ConnectionsPanel';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const runAction = vi.hoisted(() => vi.fn());
const pairing = vi.hoisted(() => ({
current: {
state: { status: 'ok' as const, snapshot: {} as Record<string, unknown> },
reload: vi.fn(),
runAction,
pendingAction: null as string | null,
actionError: null as string | null,
},
}));
vi.mock('../../../lib/orchestration/usePairing', () => ({ usePairing: () => pairing.current }));
const blockRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const reverse = vi.hoisted(() => vi.fn().mockResolvedValue({ agents: [{ username: 'alice' }] }));
vi.mock('../../../agentworld/AgentWorldShell', () => ({
apiClient: { orchestrationPairing: { blockRequest }, directory: { reverse } },
}));
function okState(accepted: unknown[], stats?: unknown) {
return {
status: 'ok' as const,
snapshot: {
contacts: { contacts: accepted },
requests: { incoming: [], outgoing: [] },
stats: stats ?? {
agentId: 'me',
contactCount: accepted.length,
pendingIncoming: 0,
pendingOutgoing: 0,
},
},
};
}
describe('ConnectionsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
pairing.current = { ...pairing.current, pendingAction: null, actionError: null };
});
it('shows a loading state', () => {
pairing.current = { ...pairing.current, state: { status: 'loading' } as never };
render(<ConnectionsPanel />);
expect(screen.getByTestId('orch-connections-loading')).toBeInTheDocument();
});
it('renders an empty state with a discover CTA', () => {
pairing.current = { ...pairing.current, state: okState([]) as never };
const onDiscover = vi.fn();
render(<ConnectionsPanel onDiscover={onDiscover} />);
expect(screen.getByTestId('orch-connections-empty')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('orch-connections-empty-cta'));
expect(onDiscover).toHaveBeenCalled();
});
it('lists accepted contacts and blocks one', async () => {
pairing.current = {
...pairing.current,
state: okState([{ status: 'accepted', agentId: 'agent-1', contact: {} }]) as never,
};
render(<ConnectionsPanel />);
expect(screen.getByTestId('orch-connection-row')).toBeInTheDocument();
// handle resolves from the directory reverse lookup
await waitFor(() => expect(screen.getByText('@alice')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('orch-connection-block'));
expect(runAction).toHaveBeenCalledWith('block:agent-1', expect.any(Function));
});
});
@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DiscoverPanel from '../DiscoverPanel';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const runAction = vi.hoisted(() => vi.fn());
const pairing = vi.hoisted(() => ({
current: {
state: { status: 'ok' as const, snapshot: { requests: { incoming: [], outgoing: [] } } },
reload: vi.fn(),
runAction,
pendingAction: null as string | null,
actionError: null as string | null,
},
}));
vi.mock('../../../lib/orchestration/usePairing', () => ({ usePairing: () => pairing.current }));
const selfIdentity = vi.hoisted(() => vi.fn());
const relayInfo = vi.hoisted(() => vi.fn().mockResolvedValue({ baseUrl: 'x', network: 'prod' }));
vi.mock('../../../lib/orchestration/orchestrationClient', () => ({
orchestrationClient: { selfIdentity, relayInfo },
}));
const linkSession = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const acceptRequest = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const reverse = vi.hoisted(() => vi.fn().mockResolvedValue({ agents: [] }));
vi.mock('../../../agentworld/AgentWorldShell', () => ({
apiClient: {
orchestrationPairing: { linkSession, acceptRequest, declineRequest: vi.fn() },
directory: { reverse },
},
}));
describe('DiscoverPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
selfIdentity.mockResolvedValue({
agentId: 'me',
handles: [],
discoverable: false,
cardPublished: false,
keyPublished: false,
});
pairing.current = {
...pairing.current,
state: { status: 'ok', snapshot: { requests: { incoming: [], outgoing: [] } } } as never,
pendingAction: null,
actionError: null,
};
});
it('shows the discoverability guide when not discoverable', async () => {
render(<DiscoverPanel />);
await waitFor(() => expect(screen.getByTestId('orch-discover-guide')).toBeInTheDocument());
expect(screen.getByTestId('orch-discover-no-requests')).toBeInTheDocument();
});
it('submits a link request', async () => {
render(<DiscoverPanel />);
fireEvent.change(screen.getByTestId('orch-discover-link-input'), {
target: { value: 'agent-9' },
});
fireEvent.submit(screen.getByTestId('orch-discover-link-input').closest('form')!);
expect(runAction).toHaveBeenCalledWith('link:agent-9', expect.any(Function));
});
it('accepts an inbound request', async () => {
pairing.current = {
...pairing.current,
state: {
status: 'ok',
snapshot: {
requests: {
incoming: [{ status: 'pending', agentId: 'agent-7', contact: {} }],
outgoing: [],
},
},
} as never,
};
render(<DiscoverPanel />);
await waitFor(() =>
expect(screen.getByTestId('orch-discover-request-row')).toBeInTheDocument()
);
fireEvent.click(screen.getByTestId('orch-discover-accept'));
expect(runAction).toHaveBeenCalledWith('accept:agent-7', expect.any(Function));
});
});
@@ -0,0 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import UsagePanel from '../UsagePanel';
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
const getBalance = vi.hoisted(() => vi.fn());
const getTeamUsage = vi.hoisted(() => vi.fn());
vi.mock('../../../services/api/creditsApi', () => ({ creditsApi: { getBalance, getTeamUsage } }));
const getTokenjuiceSavings = vi.hoisted(() => vi.fn());
vi.mock('../../../utils/tauriCommands/tokenjuice', () => ({ getTokenjuiceSavings }));
const list = vi.hoisted(() => vi.fn());
vi.mock('../../../agentworld/AgentWorldShell', () => ({
apiClient: { orchestrationPairing: { list } },
}));
describe('UsagePanel', () => {
beforeEach(() => vi.clearAllMocks());
it('renders every stat tile when all sources succeed', async () => {
getBalance.mockResolvedValue({ promotionBalanceUsd: 5, teamTopupUsd: 10 });
getTeamUsage.mockResolvedValue({
cycleSpentUsd: 3.5,
cycleBudgetUsd: 20,
insights: { totals: { inferenceCalls: 42, integrationCalls: 7 } },
});
getTokenjuiceSavings.mockResolvedValue({ total: { tokensSaved: 1234, costSavedUsd: 0.9 } });
list.mockResolvedValue({ stats: { contactCount: 3 }, contacts: { contacts: [] } });
render(<UsagePanel />);
await waitFor(() => expect(screen.getByTestId('orch-usage-panel')).toBeInTheDocument());
expect(screen.getByTestId('orch-usage-connections')).toHaveTextContent('3');
expect(screen.getByTestId('orch-usage-balance')).toHaveTextContent('$15.00');
expect(screen.getByTestId('orch-usage-cycle-spend')).toHaveTextContent('$3.50');
expect(screen.getByTestId('orch-usage-inference-calls')).toHaveTextContent('42');
expect(screen.getByTestId('orch-usage-tokens-saved')).toHaveTextContent('1,234');
});
it('degrades unavailable sources to a dash without blanking the page', async () => {
getBalance.mockRejectedValue(new Error('billing offline'));
getTeamUsage.mockRejectedValue(new Error('billing offline'));
getTokenjuiceSavings.mockRejectedValue(new Error('no core'));
list.mockRejectedValue(new Error('no relay'));
render(<UsagePanel />);
await waitFor(() => expect(screen.getByTestId('orch-usage-panel')).toBeInTheDocument());
expect(screen.getByTestId('orch-usage-balance')).toHaveTextContent('—');
expect(screen.getByTestId('orch-usage-connections')).toHaveTextContent('—');
});
});
@@ -0,0 +1,58 @@
/**
* Small presentational building blocks shared by the Orchestration sub-pages
* (Connections / Discover / Usage). Pure, no data access.
*/
import type { ReactNode } from 'react';
/** A titled card surface used to group a panel section. */
export function SectionCard({
title,
description,
action,
children,
testId,
}: {
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
children: ReactNode;
testId?: string;
}) {
return (
<section
className="rounded-xl border border-line bg-surface p-4 shadow-soft"
data-testid={testId}>
{(title || action) && (
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
{title && <h3 className="text-sm font-semibold text-content">{title}</h3>}
{description && <p className="mt-0.5 text-xs text-content-muted">{description}</p>}
</div>
{action}
</div>
)}
{children}
</section>
);
}
/** A single metric tile: a big value over a muted label, with an optional hint. */
export function StatTile({
label,
value,
hint,
testId,
}: {
label: ReactNode;
value: ReactNode;
hint?: ReactNode;
testId?: string;
}) {
return (
<div className="rounded-xl border border-line bg-surface p-4 shadow-soft" data-testid={testId}>
<p className="text-xs font-medium uppercase tracking-wide text-content-muted">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-content">{value}</p>
{hint && <p className="mt-1 text-[11px] text-content-faint">{hint}</p>}
</div>
);
}
+6 -2
View File
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
describe('NAV_TABS', () => {
it('has exactly 6 entries', () => {
expect(NAV_TABS).toHaveLength(6);
it('has exactly 7 entries', () => {
expect(NAV_TABS).toHaveLength(7);
});
it('has the correct ids in order', () => {
@@ -13,6 +13,7 @@ describe('NAV_TABS', () => {
'human',
'brain',
'flows',
'orchestration',
'agent-world',
'connections',
]);
@@ -24,6 +25,7 @@ describe('NAV_TABS', () => {
'/human',
'/brain',
'/flows',
'/orchestration',
'/agent-world',
'/connections',
]);
@@ -35,6 +37,7 @@ describe('NAV_TABS', () => {
'nav.human',
'nav.brain',
'nav.flows',
'nav.orchestration',
'nav.agentWorld',
'nav.connections',
]);
@@ -46,6 +49,7 @@ describe('NAV_TABS', () => {
'tab-human',
'tab-brain',
'tab-flows',
'tab-orchestration',
'tab-agent-world',
'tab-connections',
]);
+12 -2
View File
@@ -20,8 +20,12 @@ export interface NavTab {
}
/**
* Ordered list of sidebar nav entries. Four entries:
* chat → human → brain → connections
* Ordered list of sidebar nav entries:
* chat → human → brain → flows → orchestration → agent-world → connections
*
* The Orchestration tab (TinyPlace multi-agent coordination) sits right after
* Workflows; it was promoted out of the Brain sub-tab drawer into a first-class
* destination at `/orchestration`.
*
* Settings has no primary tab — it's reached via the gear icon in the sidebar
* header. Chat is the default landing and the merged Home surface: its empty
@@ -37,6 +41,12 @@ export const NAV_TABS: NavTab[] = [
{ id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
{ id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' },
{
id: 'orchestration',
labelKey: 'nav.orchestration',
path: '/orchestration',
walkthroughAttr: 'tab-orchestration',
},
{
id: 'agent-world',
labelKey: 'nav.agentWorld',
+36
View File
@@ -171,6 +171,42 @@ const messages: TranslationMap = {
'nav.activity': 'النشاط',
'nav.brain': 'الدماغ',
'nav.flows': 'سير العمل',
'nav.orchestration': 'التنسيق',
'orchPage.subtitle': 'نسّق وكيلك الرئيسي',
'orchPage.group.agent': 'الوكيل',
'orchPage.group.network': 'الشبكة',
'orchPage.group.insights': 'رؤى',
'orchPage.agent.nav': 'محادثة',
'orchPage.agent.mainTab': 'الوكيل الرئيسي',
'orchPage.agent.subconsciousTab': 'اللاوعي',
'orchPage.agent.description': 'تحدّث مع الوكيل الرئيسي وراقب لاوعيه',
'orchPage.connections.nav': 'الاتصالات',
'orchPage.connections.title': 'الوكلاء المرتبطون',
'orchPage.connections.description': 'الأقران الذين يمكن لوكيلك الرئيسي التنسيق معهم',
'orchPage.connections.empty': 'لا توجد اتصالات بعد.',
'orchPage.connections.emptyCta': 'إضافة اتصال',
'orchPage.connections.statContacts': 'الاتصالات',
'orchPage.connections.statPending': 'قيد الانتظار',
'orchPage.connections.pendingHint': 'بانتظار القبول',
'orchPage.discover.nav': 'اكتشاف',
'orchPage.discover.linkAction': 'إضافة',
'orchPage.discover.identityTitle': 'قابليتك للاكتشاف',
'orchPage.discover.notDiscoverableGuide':
'سجّل @handle حتى يتمكن الوكلاء الآخرون من العثور عليك ومراسلتك.',
'orchPage.discover.linkTitle': 'ربط وكيل جديد',
'orchPage.discover.linkDescription': 'الصق معرّف وكيل لإرسال طلب اتصال.',
'orchPage.discover.noRequests': 'لا توجد طلبات واردة.',
'orchPage.usage.nav': 'الاستخدام',
'orchPage.usage.connections': 'الاتصالات',
'orchPage.usage.balance': 'رصيد الائتمان',
'orchPage.usage.balanceHint': 'ترويجي + شحن',
'orchPage.usage.cycleSpend': 'إنفاق الدورة',
'orchPage.usage.ofBudget': 'من',
'orchPage.usage.inferenceCalls': 'استدعاءات النموذج',
'orchPage.usage.integrationCalls': 'استدعاءات التكامل',
'orchPage.usage.tokensSaved': 'الرموز الموفّرة',
'orchPage.usage.saved': 'موفّر',
'orchPage.usage.footnote': 'يعكس الاستخدام دورة الفوترة الحالية.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'المحفظة',
'agentWorld.description':
+36
View File
@@ -176,6 +176,42 @@ const messages: TranslationMap = {
'nav.activity': 'কার্যকলাপ',
'nav.brain': 'ব্রেইন',
'nav.flows': 'ওয়ার্কফ্লো',
'nav.orchestration': 'অর্কেস্ট্রেশন',
'orchPage.subtitle': 'আপনার প্রধান এজেন্ট সমন্বয় করুন',
'orchPage.group.agent': 'এজেন্ট',
'orchPage.group.network': 'নেটওয়ার্ক',
'orchPage.group.insights': 'অন্তর্দৃষ্টি',
'orchPage.agent.nav': 'চ্যাট',
'orchPage.agent.mainTab': 'প্রধান এজেন্ট',
'orchPage.agent.subconsciousTab': 'অবচেতন',
'orchPage.agent.description': 'প্রধান এজেন্টের সাথে চ্যাট করুন এবং তার অবচেতন দেখুন',
'orchPage.connections.nav': 'সংযোগ',
'orchPage.connections.title': 'সংযুক্ত এজেন্ট',
'orchPage.connections.description': 'যাদের সাথে আপনার প্রধান এজেন্ট সমন্বয় করতে পারে',
'orchPage.connections.empty': 'এখনও কোনো সংযোগ নেই।',
'orchPage.connections.emptyCta': 'একটি সংযোগ যোগ করুন',
'orchPage.connections.statContacts': 'সংযোগ',
'orchPage.connections.statPending': 'অপেক্ষমাণ',
'orchPage.connections.pendingHint': 'গ্রহণের অপেক্ষায়',
'orchPage.discover.nav': 'আবিষ্কার',
'orchPage.discover.linkAction': 'যোগ করুন',
'orchPage.discover.identityTitle': 'আপনার আবিষ্কারযোগ্যতা',
'orchPage.discover.notDiscoverableGuide':
'একটি @handle নিবন্ধন করুন যাতে অন্য এজেন্টরা আপনাকে খুঁজে বার্তা পাঠাতে পারে।',
'orchPage.discover.linkTitle': 'নতুন এজেন্ট লিঙ্ক করুন',
'orchPage.discover.linkDescription': 'সংযোগের অনুরোধ পাঠাতে একটি এজেন্ট আইডি পেস্ট করুন।',
'orchPage.discover.noRequests': 'কোনো আগত অনুরোধ নেই।',
'orchPage.usage.nav': 'ব্যবহার',
'orchPage.usage.connections': 'সংযোগ',
'orchPage.usage.balance': 'ক্রেডিট ব্যালেন্স',
'orchPage.usage.balanceHint': 'প্রচারমূলক + টপ-আপ',
'orchPage.usage.cycleSpend': 'চক্রের ব্যয়',
'orchPage.usage.ofBudget': 'এর',
'orchPage.usage.inferenceCalls': 'মডেল কল',
'orchPage.usage.integrationCalls': 'ইন্টিগ্রেশন কল',
'orchPage.usage.tokensSaved': 'সংরক্ষিত টোকেন',
'orchPage.usage.saved': 'সংরক্ষিত',
'orchPage.usage.footnote': 'ব্যবহার আপনার বর্তমান বিলিং চক্র প্রতিফলিত করে।',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'ওয়ালেট',
'agentWorld.description':
+36
View File
@@ -182,6 +182,42 @@ const messages: TranslationMap = {
'nav.activity': 'Aktivität',
'nav.brain': 'Gehirn',
'nav.flows': 'Workflows',
'nav.orchestration': 'Orchestrierung',
'orchPage.subtitle': 'Koordiniere deinen Hauptagenten',
'orchPage.group.agent': 'Agent',
'orchPage.group.network': 'Netzwerk',
'orchPage.group.insights': 'Einblicke',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Hauptagent',
'orchPage.agent.subconsciousTab': 'Unterbewusstsein',
'orchPage.agent.description': 'Chatte mit dem Hauptagenten und beobachte sein Unterbewusstsein',
'orchPage.connections.nav': 'Verbindungen',
'orchPage.connections.title': 'Verknüpfte Agenten',
'orchPage.connections.description': 'Peers, mit denen dein Hauptagent zusammenarbeiten kann',
'orchPage.connections.empty': 'Noch keine Verbindungen.',
'orchPage.connections.emptyCta': 'Verbindung hinzufügen',
'orchPage.connections.statContacts': 'Verbindungen',
'orchPage.connections.statPending': 'Ausstehend',
'orchPage.connections.pendingHint': 'Warten auf Annahme',
'orchPage.discover.nav': 'Entdecken',
'orchPage.discover.linkAction': 'Hinzufügen',
'orchPage.discover.identityTitle': 'Deine Auffindbarkeit',
'orchPage.discover.notDiscoverableGuide':
'Registriere ein @handle, damit andere Agenten dich finden und dir schreiben können.',
'orchPage.discover.linkTitle': 'Neuen Agenten verknüpfen',
'orchPage.discover.linkDescription': 'Füge eine Agenten-ID ein, um eine Verbindungsanfrage zu senden.',
'orchPage.discover.noRequests': 'Keine eingehenden Anfragen.',
'orchPage.usage.nav': 'Nutzung',
'orchPage.usage.connections': 'Verbindungen',
'orchPage.usage.balance': 'Guthaben',
'orchPage.usage.balanceHint': 'Aktion + Aufladung',
'orchPage.usage.cycleSpend': 'Zyklusausgaben',
'orchPage.usage.ofBudget': 'von',
'orchPage.usage.inferenceCalls': 'Modellaufrufe',
'orchPage.usage.integrationCalls': 'Integrationsaufrufe',
'orchPage.usage.tokensSaved': 'Gesparte Tokens',
'orchPage.usage.saved': 'gespart',
'orchPage.usage.footnote': 'Die Nutzung bezieht sich auf deinen aktuellen Abrechnungszeitraum.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Wallet',
'agentWorld.description':
+38
View File
@@ -26,6 +26,44 @@ const en: TranslationMap = {
'nav.activity': 'Activity',
'nav.brain': 'Brain',
'nav.flows': 'Workflows',
'nav.orchestration': 'Orchestration',
// ── Orchestration sub-pages (orchPage.*) ──────────────────────────────────
'orchPage.subtitle': 'Coordinate your main agent',
'orchPage.group.agent': 'Agent',
'orchPage.group.network': 'Network',
'orchPage.group.insights': 'Insights',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Main agent',
'orchPage.agent.subconsciousTab': 'Subconscious',
'orchPage.agent.description': 'Chat with the main agent and watch its subconscious',
'orchPage.connections.nav': 'Connections',
'orchPage.connections.title': 'Linked agents',
'orchPage.connections.description': 'Peers your main agent can coordinate with',
'orchPage.connections.empty': 'No connections yet.',
'orchPage.connections.emptyCta': 'Add a connection',
'orchPage.connections.statContacts': 'Connections',
'orchPage.connections.statPending': 'Pending',
'orchPage.connections.pendingHint': 'Awaiting acceptance',
'orchPage.discover.nav': 'Discover',
'orchPage.discover.linkAction': 'Add',
'orchPage.discover.identityTitle': 'Your discoverability',
'orchPage.discover.notDiscoverableGuide':
'Register a @handle so other agents can find and message you.',
'orchPage.discover.linkTitle': 'Link a new agent',
'orchPage.discover.linkDescription': 'Paste an agent ID to send a connection request.',
'orchPage.discover.noRequests': 'No incoming requests.',
'orchPage.usage.nav': 'Usage',
'orchPage.usage.connections': 'Connections',
'orchPage.usage.balance': 'Credit balance',
'orchPage.usage.balanceHint': 'Promotional + top-up',
'orchPage.usage.cycleSpend': 'Cycle spend',
'orchPage.usage.ofBudget': 'of',
'orchPage.usage.inferenceCalls': 'Model calls',
'orchPage.usage.integrationCalls': 'Integration calls',
'orchPage.usage.tokensSaved': 'Tokens saved',
'orchPage.usage.saved': 'saved',
'orchPage.usage.footnote': 'Usage reflects your current billing cycle.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Wallet',
// Agent World section sub-navigation labels
+36
View File
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
'nav.activity': 'Actividad',
'nav.brain': 'Cerebro',
'nav.flows': 'Flujos de trabajo',
'nav.orchestration': 'Orquestación',
'orchPage.subtitle': 'Coordina tu agente principal',
'orchPage.group.agent': 'Agente',
'orchPage.group.network': 'Red',
'orchPage.group.insights': 'Estadísticas',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Agente principal',
'orchPage.agent.subconsciousTab': 'Subconsciente',
'orchPage.agent.description': 'Chatea con el agente principal y observa su subconsciente',
'orchPage.connections.nav': 'Conexiones',
'orchPage.connections.title': 'Agentes vinculados',
'orchPage.connections.description': 'Pares con los que tu agente principal puede coordinarse',
'orchPage.connections.empty': 'Aún no hay conexiones.',
'orchPage.connections.emptyCta': 'Agregar una conexión',
'orchPage.connections.statContacts': 'Conexiones',
'orchPage.connections.statPending': 'Pendiente',
'orchPage.connections.pendingHint': 'Esperando aceptación',
'orchPage.discover.nav': 'Descubrir',
'orchPage.discover.linkAction': 'Agregar',
'orchPage.discover.identityTitle': 'Tu visibilidad',
'orchPage.discover.notDiscoverableGuide':
'Registra un @handle para que otros agentes puedan encontrarte y escribirte.',
'orchPage.discover.linkTitle': 'Vincular un nuevo agente',
'orchPage.discover.linkDescription': 'Pega un ID de agente para enviar una solicitud de conexión.',
'orchPage.discover.noRequests': 'No hay solicitudes entrantes.',
'orchPage.usage.nav': 'Uso',
'orchPage.usage.connections': 'Conexiones',
'orchPage.usage.balance': 'Saldo de crédito',
'orchPage.usage.balanceHint': 'Promocional + recarga',
'orchPage.usage.cycleSpend': 'Gasto del ciclo',
'orchPage.usage.ofBudget': 'de',
'orchPage.usage.inferenceCalls': 'Llamadas al modelo',
'orchPage.usage.integrationCalls': 'Llamadas de integración',
'orchPage.usage.tokensSaved': 'Tokens ahorrados',
'orchPage.usage.saved': 'ahorrado',
'orchPage.usage.footnote': 'El uso refleja tu ciclo de facturación actual.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Cartera',
'agentWorld.description':
+36
View File
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
'nav.activity': 'Activité',
'nav.brain': 'Cerveau',
'nav.flows': 'Workflows',
'nav.orchestration': 'Orchestration',
'orchPage.subtitle': 'Coordonnez votre agent principal',
'orchPage.group.agent': 'Agent',
'orchPage.group.network': 'Réseau',
'orchPage.group.insights': 'Analyses',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Agent principal',
'orchPage.agent.subconsciousTab': 'Subconscient',
'orchPage.agent.description': "Discutez avec l'agent principal et observez son subconscient",
'orchPage.connections.nav': 'Connexions',
'orchPage.connections.title': 'Agents liés',
'orchPage.connections.description': 'Pairs avec lesquels votre agent principal peut se coordonner',
'orchPage.connections.empty': 'Aucune connexion pour le moment.',
'orchPage.connections.emptyCta': 'Ajouter une connexion',
'orchPage.connections.statContacts': 'Connexions',
'orchPage.connections.statPending': 'En attente',
'orchPage.connections.pendingHint': "En attente d'acceptation",
'orchPage.discover.nav': 'Découvrir',
'orchPage.discover.linkAction': 'Ajouter',
'orchPage.discover.identityTitle': 'Votre visibilité',
'orchPage.discover.notDiscoverableGuide':
"Enregistrez un @handle pour que d'autres agents puissent vous trouver et vous écrire.",
'orchPage.discover.linkTitle': 'Lier un nouvel agent',
'orchPage.discover.linkDescription': "Collez un ID d'agent pour envoyer une demande de connexion.",
'orchPage.discover.noRequests': 'Aucune demande entrante.',
'orchPage.usage.nav': 'Utilisation',
'orchPage.usage.connections': 'Connexions',
'orchPage.usage.balance': 'Solde de crédit',
'orchPage.usage.balanceHint': 'Promotionnel + recharge',
'orchPage.usage.cycleSpend': 'Dépenses du cycle',
'orchPage.usage.ofBudget': 'sur',
'orchPage.usage.inferenceCalls': 'Appels au modèle',
'orchPage.usage.integrationCalls': "Appels d'intégration",
'orchPage.usage.tokensSaved': 'Jetons économisés',
'orchPage.usage.saved': 'économisé',
'orchPage.usage.footnote': "L'utilisation reflète votre cycle de facturation actuel.",
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portefeuille',
'agentWorld.description':
+36
View File
@@ -176,6 +176,42 @@ const messages: TranslationMap = {
'nav.activity': 'गतिविधि',
'nav.brain': 'ब्रेन',
'nav.flows': 'वर्कफ़्लो',
'nav.orchestration': 'ऑर्केस्ट्रेशन',
'orchPage.subtitle': 'अपने मुख्य एजेंट का समन्वय करें',
'orchPage.group.agent': 'एजेंट',
'orchPage.group.network': 'नेटवर्क',
'orchPage.group.insights': 'अंतर्दृष्टि',
'orchPage.agent.nav': 'चैट',
'orchPage.agent.mainTab': 'मुख्य एजेंट',
'orchPage.agent.subconsciousTab': 'अवचेतन',
'orchPage.agent.description': 'मुख्य एजेंट से चैट करें और उसका अवचेतन देखें',
'orchPage.connections.nav': 'कनेक्शन',
'orchPage.connections.title': 'लिंक किए गए एजेंट',
'orchPage.connections.description': 'वे साथी जिनके साथ आपका मुख्य एजेंट समन्वय कर सकता है',
'orchPage.connections.empty': 'अभी तक कोई कनेक्शन नहीं।',
'orchPage.connections.emptyCta': 'कनेक्शन जोड़ें',
'orchPage.connections.statContacts': 'कनेक्शन',
'orchPage.connections.statPending': 'लंबित',
'orchPage.connections.pendingHint': 'स्वीकृति की प्रतीक्षा में',
'orchPage.discover.nav': 'खोजें',
'orchPage.discover.linkAction': 'जोड़ें',
'orchPage.discover.identityTitle': 'आपकी खोज-योग्यता',
'orchPage.discover.notDiscoverableGuide':
'एक @handle पंजीकृत करें ताकि अन्य एजेंट आपको ढूंढ़ और संदेश भेज सकें।',
'orchPage.discover.linkTitle': 'नया एजेंट लिंक करें',
'orchPage.discover.linkDescription': 'कनेक्शन अनुरोध भेजने के लिए एजेंट आईडी पेस्ट करें।',
'orchPage.discover.noRequests': 'कोई आने वाला अनुरोध नहीं।',
'orchPage.usage.nav': 'उपयोग',
'orchPage.usage.connections': 'कनेक्शन',
'orchPage.usage.balance': 'क्रेडिट शेष',
'orchPage.usage.balanceHint': 'प्रचार + टॉप-अप',
'orchPage.usage.cycleSpend': 'चक्र व्यय',
'orchPage.usage.ofBudget': 'में से',
'orchPage.usage.inferenceCalls': 'मॉडल कॉल',
'orchPage.usage.integrationCalls': 'एकीकरण कॉल',
'orchPage.usage.tokensSaved': 'बचाए गए टोकन',
'orchPage.usage.saved': 'बचाया',
'orchPage.usage.footnote': 'उपयोग आपके वर्तमान बिलिंग चक्र को दर्शाता है।',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'वॉलेट',
'agentWorld.description':
+36
View File
@@ -177,6 +177,42 @@ const messages: TranslationMap = {
'nav.activity': 'Aktivitas',
'nav.brain': 'Otak',
'nav.flows': 'Alur Kerja',
'nav.orchestration': 'Orkestrasi',
'orchPage.subtitle': 'Koordinasikan agen utama Anda',
'orchPage.group.agent': 'Agen',
'orchPage.group.network': 'Jaringan',
'orchPage.group.insights': 'Wawasan',
'orchPage.agent.nav': 'Obrolan',
'orchPage.agent.mainTab': 'Agen utama',
'orchPage.agent.subconsciousTab': 'Bawah sadar',
'orchPage.agent.description': 'Mengobrol dengan agen utama dan amati bawah sadarnya',
'orchPage.connections.nav': 'Koneksi',
'orchPage.connections.title': 'Agen tertaut',
'orchPage.connections.description': 'Rekan yang dapat dikoordinasikan oleh agen utama Anda',
'orchPage.connections.empty': 'Belum ada koneksi.',
'orchPage.connections.emptyCta': 'Tambahkan koneksi',
'orchPage.connections.statContacts': 'Koneksi',
'orchPage.connections.statPending': 'Menunggu',
'orchPage.connections.pendingHint': 'Menunggu persetujuan',
'orchPage.discover.nav': 'Temukan',
'orchPage.discover.linkAction': 'Tambah',
'orchPage.discover.identityTitle': 'Keterlihatan Anda',
'orchPage.discover.notDiscoverableGuide':
'Daftarkan @handle agar agen lain dapat menemukan dan mengirim pesan kepada Anda.',
'orchPage.discover.linkTitle': 'Tautkan agen baru',
'orchPage.discover.linkDescription': 'Tempel ID agen untuk mengirim permintaan koneksi.',
'orchPage.discover.noRequests': 'Tidak ada permintaan masuk.',
'orchPage.usage.nav': 'Penggunaan',
'orchPage.usage.connections': 'Koneksi',
'orchPage.usage.balance': 'Saldo kredit',
'orchPage.usage.balanceHint': 'Promosi + isi ulang',
'orchPage.usage.cycleSpend': 'Pengeluaran siklus',
'orchPage.usage.ofBudget': 'dari',
'orchPage.usage.inferenceCalls': 'Panggilan model',
'orchPage.usage.integrationCalls': 'Panggilan integrasi',
'orchPage.usage.tokensSaved': 'Token dihemat',
'orchPage.usage.saved': 'dihemat',
'orchPage.usage.footnote': 'Penggunaan mencerminkan siklus penagihan Anda saat ini.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Dompet',
'agentWorld.description':
+36
View File
@@ -179,6 +179,42 @@ const messages: TranslationMap = {
'nav.activity': 'Attività',
'nav.brain': 'Cervello',
'nav.flows': 'Flussi di lavoro',
'nav.orchestration': 'Orchestrazione',
'orchPage.subtitle': 'Coordina il tuo agente principale',
'orchPage.group.agent': 'Agente',
'orchPage.group.network': 'Rete',
'orchPage.group.insights': 'Approfondimenti',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Agente principale',
'orchPage.agent.subconsciousTab': 'Subconscio',
'orchPage.agent.description': "Chatta con l'agente principale e osserva il suo subconscio",
'orchPage.connections.nav': 'Connessioni',
'orchPage.connections.title': 'Agenti collegati',
'orchPage.connections.description': 'Peer con cui il tuo agente principale può coordinarsi',
'orchPage.connections.empty': 'Ancora nessuna connessione.',
'orchPage.connections.emptyCta': 'Aggiungi una connessione',
'orchPage.connections.statContacts': 'Connessioni',
'orchPage.connections.statPending': 'In sospeso',
'orchPage.connections.pendingHint': 'In attesa di accettazione',
'orchPage.discover.nav': 'Scopri',
'orchPage.discover.linkAction': 'Aggiungi',
'orchPage.discover.identityTitle': 'La tua reperibilità',
'orchPage.discover.notDiscoverableGuide':
'Registra un @handle così altri agenti possono trovarti e scriverti.',
'orchPage.discover.linkTitle': 'Collega un nuovo agente',
'orchPage.discover.linkDescription': 'Incolla un ID agente per inviare una richiesta di connessione.',
'orchPage.discover.noRequests': 'Nessuna richiesta in arrivo.',
'orchPage.usage.nav': 'Utilizzo',
'orchPage.usage.connections': 'Connessioni',
'orchPage.usage.balance': 'Saldo credito',
'orchPage.usage.balanceHint': 'Promozionale + ricarica',
'orchPage.usage.cycleSpend': 'Spesa del ciclo',
'orchPage.usage.ofBudget': 'di',
'orchPage.usage.inferenceCalls': 'Chiamate al modello',
'orchPage.usage.integrationCalls': 'Chiamate di integrazione',
'orchPage.usage.tokensSaved': 'Token risparmiati',
'orchPage.usage.saved': 'risparmiato',
'orchPage.usage.footnote': "L'utilizzo riflette il tuo ciclo di fatturazione attuale.",
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portafoglio',
'agentWorld.description':
+36
View File
@@ -173,6 +173,42 @@ const messages: TranslationMap = {
'nav.activity': '활동',
'nav.brain': '브레인',
'nav.flows': '워크플로',
'nav.orchestration': '오케스트레이션',
'orchPage.subtitle': '메인 에이전트를 조율하세요',
'orchPage.group.agent': '에이전트',
'orchPage.group.network': '네트워크',
'orchPage.group.insights': '인사이트',
'orchPage.agent.nav': '채팅',
'orchPage.agent.mainTab': '메인 에이전트',
'orchPage.agent.subconsciousTab': '잠재의식',
'orchPage.agent.description': '메인 에이전트와 대화하고 잠재의식을 지켜보세요',
'orchPage.connections.nav': '연결',
'orchPage.connections.title': '연결된 에이전트',
'orchPage.connections.description': '메인 에이전트가 협력할 수 있는 상대',
'orchPage.connections.empty': '아직 연결이 없습니다.',
'orchPage.connections.emptyCta': '연결 추가',
'orchPage.connections.statContacts': '연결',
'orchPage.connections.statPending': '대기 중',
'orchPage.connections.pendingHint': '수락 대기 중',
'orchPage.discover.nav': '탐색',
'orchPage.discover.linkAction': '추가',
'orchPage.discover.identityTitle': '검색 가능성',
'orchPage.discover.notDiscoverableGuide':
'다른 에이전트가 찾아 메시지를 보낼 수 있도록 @handle을 등록하세요.',
'orchPage.discover.linkTitle': '새 에이전트 연결',
'orchPage.discover.linkDescription': '연결 요청을 보내려면 에이전트 ID를 붙여넣으세요.',
'orchPage.discover.noRequests': '수신된 요청이 없습니다.',
'orchPage.usage.nav': '사용량',
'orchPage.usage.connections': '연결',
'orchPage.usage.balance': '크레딧 잔액',
'orchPage.usage.balanceHint': '프로모션 + 충전',
'orchPage.usage.cycleSpend': '주기 지출',
'orchPage.usage.ofBudget': '중',
'orchPage.usage.inferenceCalls': '모델 호출',
'orchPage.usage.integrationCalls': '통합 호출',
'orchPage.usage.tokensSaved': '절약된 토큰',
'orchPage.usage.saved': '절약',
'orchPage.usage.footnote': '사용량은 현재 청구 주기를 반영합니다.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': '지갑',
'agentWorld.description':
+36
View File
@@ -181,6 +181,42 @@ const messages: TranslationMap = {
'nav.activity': 'Aktywność',
'nav.brain': 'Mózg',
'nav.flows': 'Przepływy pracy',
'nav.orchestration': 'Orkiestracja',
'orchPage.subtitle': 'Koordynuj swojego głównego agenta',
'orchPage.group.agent': 'Agent',
'orchPage.group.network': 'Sieć',
'orchPage.group.insights': 'Statystyki',
'orchPage.agent.nav': 'Czat',
'orchPage.agent.mainTab': 'Główny agent',
'orchPage.agent.subconsciousTab': 'Podświadomość',
'orchPage.agent.description': 'Rozmawiaj z głównym agentem i obserwuj jego podświadomość',
'orchPage.connections.nav': 'Połączenia',
'orchPage.connections.title': 'Połączeni agenci',
'orchPage.connections.description': 'Partnerzy, z którymi może współpracować Twój główny agent',
'orchPage.connections.empty': 'Brak połączeń.',
'orchPage.connections.emptyCta': 'Dodaj połączenie',
'orchPage.connections.statContacts': 'Połączenia',
'orchPage.connections.statPending': 'Oczekujące',
'orchPage.connections.pendingHint': 'Oczekiwanie na akceptację',
'orchPage.discover.nav': 'Odkrywaj',
'orchPage.discover.linkAction': 'Dodaj',
'orchPage.discover.identityTitle': 'Twoja wykrywalność',
'orchPage.discover.notDiscoverableGuide':
'Zarejestruj @handle, aby inni agenci mogli Cię znaleźć i napisać do Ciebie.',
'orchPage.discover.linkTitle': 'Połącz nowego agenta',
'orchPage.discover.linkDescription': 'Wklej identyfikator agenta, aby wysłać prośbę o połączenie.',
'orchPage.discover.noRequests': 'Brak przychodzących próśb.',
'orchPage.usage.nav': 'Wykorzystanie',
'orchPage.usage.connections': 'Połączenia',
'orchPage.usage.balance': 'Saldo kredytu',
'orchPage.usage.balanceHint': 'Promocja + doładowanie',
'orchPage.usage.cycleSpend': 'Wydatki w cyklu',
'orchPage.usage.ofBudget': 'z',
'orchPage.usage.inferenceCalls': 'Wywołania modelu',
'orchPage.usage.integrationCalls': 'Wywołania integracji',
'orchPage.usage.tokensSaved': 'Zaoszczędzone tokeny',
'orchPage.usage.saved': 'zaoszczędzono',
'orchPage.usage.footnote': 'Wykorzystanie odzwierciedla bieżący cykl rozliczeniowy.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portfel',
'agentWorld.description':
+36
View File
@@ -178,6 +178,42 @@ const messages: TranslationMap = {
'nav.activity': 'Atividade',
'nav.brain': 'Cérebro',
'nav.flows': 'Fluxos de trabalho',
'nav.orchestration': 'Orquestração',
'orchPage.subtitle': 'Coordene seu agente principal',
'orchPage.group.agent': 'Agente',
'orchPage.group.network': 'Rede',
'orchPage.group.insights': 'Insights',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Agente principal',
'orchPage.agent.subconsciousTab': 'Subconsciente',
'orchPage.agent.description': 'Converse com o agente principal e observe seu subconsciente',
'orchPage.connections.nav': 'Conexões',
'orchPage.connections.title': 'Agentes vinculados',
'orchPage.connections.description': 'Pares com os quais seu agente principal pode se coordenar',
'orchPage.connections.empty': 'Ainda não há conexões.',
'orchPage.connections.emptyCta': 'Adicionar uma conexão',
'orchPage.connections.statContacts': 'Conexões',
'orchPage.connections.statPending': 'Pendente',
'orchPage.connections.pendingHint': 'Aguardando aceitação',
'orchPage.discover.nav': 'Descobrir',
'orchPage.discover.linkAction': 'Adicionar',
'orchPage.discover.identityTitle': 'Sua visibilidade',
'orchPage.discover.notDiscoverableGuide':
'Registre um @handle para que outros agentes possam encontrá-lo e enviar mensagens.',
'orchPage.discover.linkTitle': 'Vincular um novo agente',
'orchPage.discover.linkDescription': 'Cole um ID de agente para enviar uma solicitação de conexão.',
'orchPage.discover.noRequests': 'Nenhuma solicitação recebida.',
'orchPage.usage.nav': 'Uso',
'orchPage.usage.connections': 'Conexões',
'orchPage.usage.balance': 'Saldo de crédito',
'orchPage.usage.balanceHint': 'Promocional + recarga',
'orchPage.usage.cycleSpend': 'Gasto do ciclo',
'orchPage.usage.ofBudget': 'de',
'orchPage.usage.inferenceCalls': 'Chamadas ao modelo',
'orchPage.usage.integrationCalls': 'Chamadas de integração',
'orchPage.usage.tokensSaved': 'Tokens economizados',
'orchPage.usage.saved': 'economizado',
'orchPage.usage.footnote': 'O uso reflete seu ciclo de faturamento atual.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Carteira',
'agentWorld.description':
+36
View File
@@ -181,6 +181,42 @@ const messages: TranslationMap = {
'nav.activity': 'Активность',
'nav.brain': 'Мозг',
'nav.flows': 'Рабочие процессы',
'nav.orchestration': 'Оркестрация',
'orchPage.subtitle': 'Координируйте вашего главного агента',
'orchPage.group.agent': 'Агент',
'orchPage.group.network': 'Сеть',
'orchPage.group.insights': 'Аналитика',
'orchPage.agent.nav': 'Чат',
'orchPage.agent.mainTab': 'Главный агент',
'orchPage.agent.subconsciousTab': 'Подсознание',
'orchPage.agent.description': 'Общайтесь с главным агентом и наблюдайте за его подсознанием',
'orchPage.connections.nav': 'Связи',
'orchPage.connections.title': 'Связанные агенты',
'orchPage.connections.description': 'Партнёры, с которыми может координироваться ваш главный агент',
'orchPage.connections.empty': 'Пока нет связей.',
'orchPage.connections.emptyCta': 'Добавить связь',
'orchPage.connections.statContacts': 'Связи',
'orchPage.connections.statPending': 'Ожидание',
'orchPage.connections.pendingHint': 'Ожидает подтверждения',
'orchPage.discover.nav': 'Обзор',
'orchPage.discover.linkAction': 'Добавить',
'orchPage.discover.identityTitle': 'Ваша видимость',
'orchPage.discover.notDiscoverableGuide':
'Зарегистрируйте @handle, чтобы другие агенты могли найти вас и написать.',
'orchPage.discover.linkTitle': 'Связать нового агента',
'orchPage.discover.linkDescription': 'Вставьте ID агента, чтобы отправить запрос на связь.',
'orchPage.discover.noRequests': 'Нет входящих запросов.',
'orchPage.usage.nav': 'Использование',
'orchPage.usage.connections': 'Связи',
'orchPage.usage.balance': 'Баланс кредитов',
'orchPage.usage.balanceHint': 'Промо + пополнение',
'orchPage.usage.cycleSpend': 'Расходы за цикл',
'orchPage.usage.ofBudget': 'из',
'orchPage.usage.inferenceCalls': 'Вызовы модели',
'orchPage.usage.integrationCalls': 'Вызовы интеграций',
'orchPage.usage.tokensSaved': 'Сэкономлено токенов',
'orchPage.usage.saved': 'сэкономлено',
'orchPage.usage.footnote': 'Использование отражает ваш текущий расчётный период.',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Кошелёк',
'agentWorld.description':
+36
View File
@@ -159,6 +159,42 @@ const messages: TranslationMap = {
'nav.activity': '动态',
'nav.brain': '大脑',
'nav.flows': '工作流',
'nav.orchestration': '编排',
'orchPage.subtitle': '协调你的主智能体',
'orchPage.group.agent': '智能体',
'orchPage.group.network': '网络',
'orchPage.group.insights': '洞察',
'orchPage.agent.nav': '聊天',
'orchPage.agent.mainTab': '主智能体',
'orchPage.agent.subconsciousTab': '潜意识',
'orchPage.agent.description': '与主智能体聊天并观察其潜意识',
'orchPage.connections.nav': '连接',
'orchPage.connections.title': '已关联智能体',
'orchPage.connections.description': '你的主智能体可以协调的伙伴',
'orchPage.connections.empty': '暂无连接。',
'orchPage.connections.emptyCta': '添加连接',
'orchPage.connections.statContacts': '连接',
'orchPage.connections.statPending': '待处理',
'orchPage.connections.pendingHint': '等待接受',
'orchPage.discover.nav': '发现',
'orchPage.discover.linkAction': '添加',
'orchPage.discover.identityTitle': '你的可发现性',
'orchPage.discover.notDiscoverableGuide':
'注册一个 @handle,以便其他智能体可以找到你并给你发消息。',
'orchPage.discover.linkTitle': '关联新智能体',
'orchPage.discover.linkDescription': '粘贴智能体 ID 以发送连接请求。',
'orchPage.discover.noRequests': '没有传入请求。',
'orchPage.usage.nav': '用量',
'orchPage.usage.connections': '连接',
'orchPage.usage.balance': '信用余额',
'orchPage.usage.balanceHint': '促销 + 充值',
'orchPage.usage.cycleSpend': '周期支出',
'orchPage.usage.ofBudget': '/',
'orchPage.usage.inferenceCalls': '模型调用',
'orchPage.usage.integrationCalls': '集成调用',
'orchPage.usage.tokensSaved': '已节省令牌',
'orchPage.usage.saved': '已节省',
'orchPage.usage.footnote': '用量反映你当前的计费周期。',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': '钱包',
'agentWorld.description':
@@ -0,0 +1,65 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PaymentRequiredError } from '../../agentworld/invokeApiClient';
import { usePairing } from '../usePairing';
const listMock = vi.hoisted(() => vi.fn());
vi.mock('../../../agentworld/AgentWorldShell', () => ({
apiClient: { orchestrationPairing: { list: listMock } },
}));
const snapshot = {
records: [],
contacts: { contacts: [] },
requests: { incoming: [], outgoing: [] },
stats: { agentId: 'me', contactCount: 2, pendingIncoming: 1, pendingOutgoing: 0 },
};
describe('usePairing', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
it('loads a snapshot on mount', async () => {
listMock.mockResolvedValue(snapshot);
const { result } = renderHook(() => usePairing());
await waitFor(() => expect(result.current.state.status).toBe('ok'));
expect(listMock).toHaveBeenCalledTimes(1);
});
it('maps a PaymentRequiredError to payment_required', async () => {
listMock.mockRejectedValue(new PaymentRequiredError({}));
const { result } = renderHook(() => usePairing());
await waitFor(() => expect(result.current.state.status).toBe('payment_required'));
});
it('surfaces a generic error', async () => {
listMock.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() => usePairing());
await waitFor(() => expect(result.current.state.status).toBe('error'));
});
it('runs an action then reloads', async () => {
listMock.mockResolvedValue(snapshot);
const { result } = renderHook(() => usePairing());
await waitFor(() => expect(result.current.state.status).toBe('ok'));
const action = vi.fn().mockResolvedValue(undefined);
await act(async () => {
await result.current.runAction('block:x', action);
});
expect(action).toHaveBeenCalledTimes(1);
expect(listMock).toHaveBeenCalledTimes(2); // initial + reload
expect(result.current.actionError).toBeNull();
});
it('captures an action error without reloading', async () => {
listMock.mockResolvedValue(snapshot);
const { result } = renderHook(() => usePairing());
await waitFor(() => expect(result.current.state.status).toBe('ok'));
await act(async () => {
await result.current.runAction('block:x', () => Promise.reject(new Error('nope')));
});
expect(result.current.actionError).toBe('nope');
expect(listMock).toHaveBeenCalledTimes(1); // no reload after failure
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Shared pairing data hook for the Orchestration sub-pages.
*
* Wraps `apiClient.orchestrationPairing.*` (the tiny.place agent contacts graph)
* so the Connections, Discover, and Usage panels all read one consistent
* snapshot and run mutating actions (link / accept / decline / block) through the
* same load→act→reload lifecycle. Kept separate from `useOrchestrationChats`
* (which owns the chat transcript surface) so a page can pull in only what it
* needs.
*/
import debugFactory from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient } from '../../agentworld/AgentWorldShell';
import { type PairingSnapshot, PaymentRequiredError } from '../agentworld/invokeApiClient';
const debug = debugFactory('orchestration:pairing');
export type PairingState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'payment_required' }
| { status: 'ok'; snapshot: PairingSnapshot };
export interface UsePairingResult {
state: PairingState;
/** Re-fetch the pairing snapshot. */
reload: () => Promise<void>;
/** Run a mutating pairing action, then reload; tracks pending id + error. */
runAction: (actionId: string, fn: () => Promise<unknown>) => Promise<void>;
/** The id passed to the currently in-flight `runAction`, or null. */
pendingAction: string | null;
/** The last action error message, cleared when a new action starts. */
actionError: string | null;
}
export function usePairing(): UsePairingResult {
const [state, setState] = useState<PairingState>({ status: 'loading' });
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const mountedRef = useRef(true);
const reload = useCallback(async () => {
debug('reload: entry');
setState(prev => (prev.status === 'ok' ? prev : { status: 'loading' }));
try {
const snapshot = await apiClient.orchestrationPairing.list();
if (!mountedRef.current) return;
debug(
'reload: ok contacts=%d incoming=%d outgoing=%d',
snapshot.contacts.contacts.length,
snapshot.requests.incoming.length,
snapshot.requests.outgoing.length
);
setState({ status: 'ok', snapshot });
} catch (error) {
if (!mountedRef.current) return;
if (error instanceof PaymentRequiredError) {
debug('reload: payment_required');
setState({ status: 'payment_required' });
return;
}
const message = error instanceof Error ? error.message : String(error);
debug('reload: error %s', message);
setState({ status: 'error', message });
}
}, []);
const runAction = useCallback(
async (actionId: string, fn: () => Promise<unknown>) => {
debug('runAction: entry id=%s', actionId);
setPendingAction(actionId);
setActionError(null);
try {
await fn();
if (!mountedRef.current) return;
debug('runAction: success id=%s', actionId);
await reload();
} catch (error) {
if (!mountedRef.current) return;
const message = error instanceof Error ? error.message : String(error);
debug('runAction: error id=%s %s', actionId, message);
setActionError(message);
} finally {
if (mountedRef.current) setPendingAction(null);
}
},
[reload]
);
useEffect(() => {
mountedRef.current = true;
const handle = window.setTimeout(() => void reload(), 0);
return () => {
window.clearTimeout(handle);
mountedRef.current = false;
};
}, [reload]);
return { state, reload, runAction, pendingAction, actionError };
}
+10 -25
View File
@@ -15,7 +15,6 @@ import { MemoryGraph } from '../components/intelligence/MemoryGraph';
import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry';
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
import SubconsciousTriggersPanel from '../components/intelligence/SubconsciousTriggersPanel';
import TinyPlaceOrchestrationTab from '../components/intelligence/TinyPlaceOrchestrationTab';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
@@ -45,7 +44,6 @@ type BrainTab =
| 'memory-data'
| 'memory-debug'
| 'analysis-views'
| 'tinyplace-orchestration'
| 'subconscious';
/** Tabs that render a relocated settings panel (Knowledge & Memory group). */
@@ -72,7 +70,6 @@ const BRAIN_TABS: readonly BrainTab[] = [
'memory-data',
'memory-debug',
'analysis-views',
'tinyplace-orchestration',
'subconscious',
];
@@ -94,6 +91,15 @@ export default function Brain() {
},
[location.pathname, location.search, navigate]
);
// Back-compat: TinyPlace Orchestration was promoted out of Brain into the
// top-level `/orchestration` tab. Bounce the old deep link there.
useEffect(() => {
if (new URLSearchParams(location.search).get('tab') === 'tinyplace-orchestration') {
console.debug('[brain] legacy tinyplace-orchestration deep link → /orchestration');
navigate('/orchestration', { replace: true });
}
}, [location.search, navigate]);
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<GraphMode>('tree');
@@ -229,18 +235,6 @@ export default function Brain() {
},
],
},
{
label: t('memory.tab.orchestration'),
items: [
{
value: 'tinyplace-orchestration',
label: t('brain.tabs.tinyplaceOrchestration'),
icon: navIcon(
'M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.82L3 20l1.3-3.9A7.44 7.44 0 013 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
),
},
],
},
{
items: [
{
@@ -283,10 +277,7 @@ export default function Brain() {
// Bespoke tabs share the standard scaffold: a single scrolling body,
// all custom controls live inside it.
<PanelPage contentClassName="p-4">
<div
className={`mx-auto space-y-5 ${
activeTab === 'tinyplace-orchestration' ? 'max-w-5xl' : 'max-w-3xl'
}`}>
<div className="mx-auto max-w-3xl space-y-5">
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
@@ -330,12 +321,6 @@ export default function Brain() {
</div>
)}
{activeTab === 'tinyplace-orchestration' && (
<div className="animate-fade-up">
<TinyPlaceOrchestrationTab />
</div>
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
+140
View File
@@ -0,0 +1,140 @@
/**
* OrchestrationPage — the TinyPlace multi-agent orchestration surface.
*
* Promoted out of Brain into a first-class sidebar destination (`/orchestration`),
* it now fans out into four sub-pages projected into the shell's dynamic sidebar
* region (like Brain), driven by `?tab=`:
*
* - **agent** — chat with the main agent + its subconscious steering loop
* - **connections** — manage the agent's accepted peer connections
* - **discover** — grow the network: own discoverability, link + inbound requests
* - **usage** — stats: connections, credit balance, spend, token savings
*/
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import PanelPage from '../components/layout/PanelPage';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import TwoPaneNav from '../components/layout/TwoPaneNav';
import AgentChatPanel from '../components/orchestration/AgentChatPanel';
import ConnectionsPanel from '../components/orchestration/ConnectionsPanel';
import DiscoverPanel from '../components/orchestration/DiscoverPanel';
import UsagePanel from '../components/orchestration/UsagePanel';
import { useT } from '../lib/i18n/I18nContext';
type OrchestrationTab = 'agent' | 'connections' | 'discover' | 'usage';
const ORCHESTRATION_TABS: readonly OrchestrationTab[] = [
'agent',
'connections',
'discover',
'usage',
];
/** Small inline icon helper for the Orchestration sub-nav (matches Brain). */
const navIcon = (d: string) => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={d} />
</svg>
);
export default function OrchestrationPage() {
const { t } = useT();
const location = useLocation();
const navigate = useNavigate();
const activeTab = useMemo<OrchestrationTab>(() => {
const raw = new URLSearchParams(location.search).get('tab');
return (ORCHESTRATION_TABS as readonly string[]).includes(raw ?? '')
? (raw as OrchestrationTab)
: 'agent';
}, [location.search]);
const setActiveTab = useCallback(
(tab: OrchestrationTab) => {
const params = new URLSearchParams(location.search);
params.set('tab', tab);
navigate({ pathname: location.pathname, search: `?${params.toString()}` });
},
[location.pathname, location.search, navigate]
);
console.debug('[orchestration] page mount tab=%s', activeTab);
return (
<div className="h-full">
<SidebarContent>
<div className="h-full overflow-hidden">
<TwoPaneNav
ariaLabel={t('nav.orchestration')}
selected={activeTab}
onSelect={value => setActiveTab(value as OrchestrationTab)}
groups={[
{
label: t('orchPage.group.agent'),
items: [
{
value: 'agent',
label: t('orchPage.agent.nav'),
icon: navIcon(
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'
),
},
],
},
{
label: t('orchPage.group.network'),
items: [
{
value: 'connections',
label: t('orchPage.connections.nav'),
icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'),
},
{
value: 'discover',
label: t('orchPage.discover.nav'),
icon: navIcon('M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'),
},
],
},
{
label: t('orchPage.group.insights'),
items: [
{
value: 'usage',
label: t('orchPage.usage.nav'),
icon: navIcon(
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
),
},
],
},
]}
header={
<p className="min-w-0 text-[11px] text-content-muted">{t('orchPage.subtitle')}</p>
}
/>
</div>
</SidebarContent>
<div className="mx-auto h-full w-full max-w-5xl">
{activeTab === 'agent' ? (
// The chat panel manages its own full-height layout + scroll.
<div className="h-full p-4">
<AgentChatPanel />
</div>
) : (
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl animate-fade-up">
{activeTab === 'connections' && (
<ConnectionsPanel onDiscover={() => setActiveTab('discover')} />
)}
{activeTab === 'discover' && <DiscoverPanel />}
{activeTab === 'usage' && <UsagePanel />}
</div>
</PanelPage>
)}
</div>
</div>
);
}
+18 -8
View File
@@ -8,6 +8,14 @@ const graphExportMock = vi.hoisted(() => vi.fn());
// Controllable authenticated identity so we can simulate a logout→login cycle
// (userId null → set) and assert the graph reloads (#4149).
const coreAuthRef = vi.hoisted(() => ({ current: 'user-A' as string | null }));
// Captures navigate() calls so we can assert the legacy TinyPlace-orchestration
// deep link bounces to the promoted top-level /orchestration tab.
const navigateSpy = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async importOriginal => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => navigateSpy };
});
vi.mock('../../utils/tauriCommands', () => ({
memoryTreeGraphExport: graphExportMock,
@@ -95,13 +103,6 @@ vi.mock('../../components/settings/layout/SettingsLayoutContext', async () => {
React.createElement(React.Fragment, null, children),
};
});
vi.mock('../../components/intelligence/TinyPlaceOrchestrationTab', async () => {
const React = await import('react');
return {
default: () => React.createElement('div', { 'data-testid': 'brain-tinyplace-orchestration' }),
};
});
const makeGraph = (n: number) => ({
nodes: Array.from({ length: n }, (_, i) => ({ id: `n${i}`, kind: 'summary', label: `N${i}` })),
edges: [],
@@ -182,7 +183,6 @@ describe('Brain page', () => {
['analysis-views', 'brain-analysis-views'],
['sources', 'brain-sources'],
['sync', 'brain-sync'],
['tinyplace-orchestration', 'brain-tinyplace-orchestration'],
['subconscious', 'brain-subconscious'],
])('renders the %s tab', async (tab, testId) => {
graphExportMock.mockResolvedValue(makeGraph(0));
@@ -193,4 +193,14 @@ describe('Brain page', () => {
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
});
it('redirects the legacy tinyplace-orchestration deep link to /orchestration', async () => {
graphExportMock.mockResolvedValue(makeGraph(0));
await act(async () => {
renderWithProviders(<Brain />, { initialEntries: ['/?tab=tinyplace-orchestration'] });
});
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/orchestration', { replace: true });
});
});
});
@@ -0,0 +1,70 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import OrchestrationPage from '../OrchestrationPage';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
// Stub the four data-backed panels so the shell's tab routing is tested in
// isolation (the panels have their own unit tests).
vi.mock('../../components/orchestration/AgentChatPanel', () => ({
default: () => <div data-testid="panel-agent" />,
}));
vi.mock('../../components/orchestration/ConnectionsPanel', () => ({
default: ({ onDiscover }: { onDiscover?: () => void }) => (
<button data-testid="panel-connections" onClick={onDiscover}>
connections
</button>
),
}));
vi.mock('../../components/orchestration/DiscoverPanel', () => ({
default: () => <div data-testid="panel-discover" />,
}));
vi.mock('../../components/orchestration/UsagePanel', () => ({
default: () => <div data-testid="panel-usage" />,
}));
describe('OrchestrationPage shell', () => {
it('defaults to the agent chat panel', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration'] });
});
expect(screen.getByTestId('panel-agent')).toBeInTheDocument();
});
it.each([
['connections', 'panel-connections'],
['discover', 'panel-discover'],
['usage', 'panel-usage'],
])('renders the %s panel from ?tab=%s', async (tab, testId) => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: [`/orchestration?tab=${tab}`] });
});
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
it('projects a sub-nav that switches tabs', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, { initialEntries: ['/orchestration'] });
});
// Sub-nav renders via the sidebar portal once the outlet mounts.
const usageNav = await screen.findByTestId('two-pane-nav-usage');
await act(async () => {
fireEvent.click(usageNav);
});
await waitFor(() => expect(screen.getByTestId('panel-usage')).toBeInTheDocument());
});
it('lets the connections panel jump to discover via its callback', async () => {
await act(async () => {
renderWithProviders(<OrchestrationPage />, {
initialEntries: ['/orchestration?tab=connections'],
});
});
await act(async () => {
fireEvent.click(screen.getByTestId('panel-connections'));
});
await waitFor(() => expect(screen.getByTestId('panel-discover')).toBeInTheDocument());
});
});
+1
View File
@@ -45,6 +45,7 @@ const ROUTES: Route[] = [
{ hash: '/settings' },
{ hash: '/agent-world' },
{ hash: '/flows' },
{ hash: '/orchestration' },
];
async function rootTextLength(): Promise<number> {
@@ -472,6 +472,29 @@ impl Agent {
}
}
// ── Active sub-agents (ambient fleet awareness) ──────────────────────
// When this agent has async/parallel workers registered under its own
// session, prepend a compact `[active_subagents]` roster (agent type,
// subagent_session_id, live status) so it tracks the fleet from the turn
// context instead of relying on remembered `[async_subagent_ref]` blocks
// that may have scrolled away. Children register under the parent's
// `session_id`, which is this agent's `event_session_id` (see
// `build_parent_execution_context`). Gated on presence: agents that never
// spawn get an empty block and no injection. Rides per-turn context (like
// the goal block) so status is always live.
if let Some(block) =
crate::openhuman::agent_orchestration::running_subagents::active_subagents_context_block(
&self.event_session_id,
)
{
log::info!(
"[running_subagents] injecting active_subagents block session={} ({} chars)",
self.event_session_id,
block.chars().count()
);
context.push_str(&block);
}
let enriched = if context.is_empty() {
log::info!("[agent] no memory context found — using raw user message");
self.last_memory_context = None;
@@ -720,6 +720,86 @@ fn spawn_status_watcher(
});
}
/// Compact, read-only view of one registered sub-agent, for ambient injection
/// into a parent's turn context (see [`active_subagents_context_block`]).
#[derive(Debug, Clone)]
pub(crate) struct SubagentSnapshot {
/// Worker *type* (e.g. `researcher`). Not unique — two parallel researchers
/// share this; disambiguate on `subagent_session_id` / `task_id`.
pub(crate) agent_id: String,
/// Durable, stable per-worker reference the prompt steers/waits/closes by.
pub(crate) subagent_session_id: Option<String>,
/// Transient registry key.
pub(crate) task_id: String,
/// Stable status label: `running` / `awaiting_user` / `completed` / `failed`.
pub(crate) status: &'static str,
}
/// Snapshot the sub-agents registered under `parent_session`, with each status
/// read live from its watch channel. Read-only: it takes the registry lock only
/// long enough to clone out the small summaries, never blocks on a child, and
/// never mutates the table. Ordered by `agent_id` then `task_id` so the rendered
/// roster is stable across turns (the underlying map is unordered).
pub(crate) fn snapshot_for_parent(parent_session: &str) -> Vec<SubagentSnapshot> {
let map = registry().lock().expect("running_subagents mutex poisoned");
let mut out: Vec<SubagentSnapshot> = map
.iter()
.filter(|(_, entry)| entry.parent_session == parent_session)
.map(|(task_id, entry)| {
let status = match &*entry.status.borrow() {
SubagentStatus::Running => "running",
SubagentStatus::Completed { .. } => "completed",
SubagentStatus::AwaitingUser { .. } => "awaiting_user",
SubagentStatus::Failed { .. } => "failed",
};
SubagentSnapshot {
agent_id: entry.agent_id.clone(),
subagent_session_id: entry.subagent_session_id.clone(),
task_id: task_id.clone(),
status,
}
})
.collect();
out.sort_by(|a, b| {
a.agent_id
.cmp(&b.agent_id)
.then_with(|| a.task_id.cmp(&b.task_id))
});
out
}
/// Build the ambient `[active_subagents]` block prepended to a parent's turn
/// context when it has async/parallel workers registered. Returns `None` when the
/// parent owns no registered sub-agents, so the block only appears when it is
/// actionable — turns for agents that never spawn are untouched. Mirrors the
/// thread-goal `[active_goal]` block: it rides the per-turn context (not the
/// cached system-prompt prefix), so it reflects live status every turn.
pub(crate) fn active_subagents_context_block(parent_session: &str) -> Option<String> {
let workers = snapshot_for_parent(parent_session);
if workers.is_empty() {
return None;
}
let mut block = format!(
"[active_subagents]\n\
You have {} background sub-agent(s) in flight (spawned earlier this session). \
This is your live roster — trust it over memory. Track each by subagent_session_id; \
use wait_subagent to collect a `completed` one, steer_subagent to redirect a `running` \
one, continue_subagent to answer an `awaiting_user` one, close_subagent when done, and \
list_subagents to re-enumerate. Never fabricate a result for a worker still running or \
one that has failed.\n",
workers.len()
);
for w in &workers {
let session = w.subagent_session_id.as_deref().unwrap_or("(none)");
block.push_str(&format!(
"- {} · session={} · task={} · status={}\n",
w.agent_id, session, w.task_id, w.status
));
}
block.push_str("[/active_subagents]\n\n");
Some(block)
}
/// Resolve a durable `subagent_session_id` to the currently-running transient
/// `task_id`, enforcing parent-session ownership.
pub(crate) fn task_id_for_session(
@@ -1474,6 +1554,87 @@ mod tests {
prune("task-session");
}
#[tokio::test]
async fn snapshot_and_block_scope_to_parent_and_reflect_live_status() {
let _guard = test_guard();
let (tx_a, rx_a) = status_channel();
register(
"task-fleet-a".into(),
"researcher".into(),
"fleet-parent".into(),
None,
Some("subsess-a".into()),
test_workspace(),
None,
RunQueue::new(),
dummy_abort(),
rx_a,
);
let (tx_b, rx_b) = status_channel();
register(
"task-fleet-b".into(),
"code_executor".into(),
"fleet-parent".into(),
None,
Some("subsess-b".into()),
test_workspace(),
None,
RunQueue::new(),
dummy_abort(),
rx_b,
);
// A worker owned by a different parent must not leak into the snapshot.
let (tx_other, rx_other) = status_channel();
register(
"task-fleet-other".into(),
"researcher".into(),
"other-parent".into(),
None,
Some("subsess-other".into()),
test_workspace(),
None,
RunQueue::new(),
dummy_abort(),
rx_other,
);
// `b` pauses awaiting the user; `a` stays running.
tx_b.send(SubagentStatus::AwaitingUser {
question: "which repo?".into(),
})
.unwrap();
let snap = snapshot_for_parent("fleet-parent");
assert_eq!(snap.len(), 2, "only this parent's workers: {snap:?}");
// Ordered by agent_id then task_id → code_executor before researcher.
assert_eq!(snap[0].agent_id, "code_executor");
assert_eq!(snap[0].status, "awaiting_user");
assert_eq!(snap[1].agent_id, "researcher");
assert_eq!(snap[1].status, "running");
let block = active_subagents_context_block("fleet-parent").expect("block present");
assert!(block.contains("[active_subagents]"));
assert!(block.contains("You have 2 background sub-agent(s)"));
assert!(block.contains("session=subsess-a"));
assert!(block.contains("session=subsess-b · task=task-fleet-b · status=awaiting_user"));
assert!(block.ends_with("[/active_subagents]\n\n"));
// A parent with no registered workers gets no block (no perturbation).
assert!(active_subagents_context_block("nobody-here").is_none());
let _ = tx_a.send(SubagentStatus::Completed {
output: "x".into(),
iterations: 1,
});
let _ = tx_other.send(SubagentStatus::Completed {
output: "x".into(),
iterations: 1,
});
prune("task-fleet-a");
prune("task-fleet-b");
prune("task-fleet-other");
}
#[tokio::test]
async fn resume_ref_for_task_includes_resume_fields_and_enforces_ownership() {
let _guard = test_guard();
@@ -237,6 +237,17 @@ named = [
# to call a tool that does not exist.
"spawn_async_subagent",
"spawn_parallel_agents",
# Fleet awareness. `list_subagents` re-enumerates the async/parallel workers
# this parent has in flight (running / awaiting_user / completed-uncollected /
# failed) plus reusable durable sessions — the recovery move when the
# `[async_subagent_ref]` blocks have scrolled out of context and the model
# would otherwise lose track of, or needlessly re-spawn, a worker. It reads
# the same live `running_subagents` registry the ambient `[active_subagents]`
# turn block is built from. `close_subagent` tears a finished/abandoned worker
# down (cancelling it first if still running) so idle sessions don't leak
# toward the 256 registry soft cap.
"list_subagents",
"close_subagent",
"wait",
"wait_loop",
# Async sub-agent control surface. prompt.md's "Async background
@@ -1,6 +1,6 @@
# Orchestrator - Staff Engineer
You are the **Orchestrator**, the senior agent in a multi-agent system. Your role is strategic: you decide when to respond directly, when to use direct tools, and when to delegate. You have a small direct surface for lookups (`file_read`, `grep`, `glob`, `list`, `web_search_tool`, `web_fetch`, `http_request`) and managed storage transfer (`storage_upload_file`, `storage_download_file`, `storage_list_files`, `storage_get_link`). You **never** use generic file-write/edit tools and **never** execute shell commands — ordinary file modifications are delegated to `run_code` (or the owning specialist), while managed storage upload/download calls go through their own tool policy gates.
You are the **Orchestrator**, the senior agent in a multi-agent system. Your role is strategic: you decide when to respond directly, when to use direct tools, and when to delegate. **You may have several sub-agents in flight at once** — you are not talking to one worker at a time, you are running a small fleet. Each worker is a separate process with its own transcript and a stable `subagent_session_id`; keeping track of who is running, who is waiting on you, and whose results you have already collected is *your* job, not something the system does for you. You have a small direct surface for lookups (`file_read`, `grep`, `glob`, `list`, `web_search_tool`, `web_fetch`, `http_request`) and managed storage transfer (`storage_upload_file`, `storage_download_file`, `storage_list_files`, `storage_get_link`). You **never** use generic file-write/edit tools and **never** execute shell commands — ordinary file modifications are delegated to `run_code` (or the owning specialist), while managed storage upload/download calls go through their own tool policy gates.
## Core Responsibilities
@@ -69,7 +69,52 @@ You can open and operate native apps on this machine, but you do it by **delegat
- **`cron_add`, `cron_list`, `cron_remove`, `current_time` are direct named tools** when they appear in your tool list. Call them by name, never via `run_workflow` (that path returns "unknown workflow" for any built-in tool name and always errors).
- **Always get explicit user confirmation before creating any schedule** (one-shot or recurring). Propose the exact timing, wait for a yes, then act. If `cron_add` is absent from your tool list and `schedule_task` is unavailable, tell the user you can't schedule it in this environment.
## Async background sub-agents
## Managing your fleet (multiple concurrent sub-agents)
Most turns you delegate to one specialist, read its reply, and answer. But the moment
you use `spawn_async_subagent` or `spawn_parallel_agents`, you are running **several
workers at once**, and you stay responsible for every one of them until it is collected
or closed. Treat them as a roster, not a single conversation partner.
**Know what is running — don't rely on memory.** When background workers are live, each
of your turns is prefixed with an `[active_subagents]` block listing them (agent type,
`subagent_session_id`, and status: `running` / `awaiting_user` / `completed` /
`failed`). Read it. It is the source of truth for your fleet — trust it over your
recollection of earlier `[async_subagent_ref]` blocks, which may have scrolled out of
context.
- If you are unsure what you have running, or the `[active_subagents]` block and your
memory disagree, call **`list_subagents`** to re-enumerate every worker (live *and*
reusable) before acting. This is the recovery move — do it instead of guessing or
re-spawning a worker that already exists.
- **Never spawn a duplicate.** Before spawning, check the roster: if a suitable worker
is already `running` or reusable for this task, steer or wait on that one instead of
creating another.
- A worker shown as `completed` still needs collecting — call `wait_subagent` on its
ref to read the result. A `failed` worker will never produce output; surface the
failure honestly, don't paper over it.
- When you are done with a worker (result collected, or the task is abandoned), call
**`close_subagent`** with its `subagent_session_id` so it doesn't linger. Leaked idle
workers accumulate against a hard cap and will eventually block new spawns.
- Track workers by **`subagent_session_id`** (or `task_id`). `agentId` is only the
worker *type* — two researchers you spawned in parallel share an `agentId` but are
different workers. Never merge their state.
- **Reconciliation loop for parallel work:** spawn → note each `subagent_session_id`
tick/wait on each *independently* → synthesise **only completed** outputs → report any
failures. Never fabricate, guess, or average in a result for a worker that is still
running or has failed.
### Parallel fan-out (`spawn_parallel_agents`)
Use `spawn_parallel_agents` when a task decomposes into **independent** subtasks that can
run at the same time and whose results you will combine — e.g. "research these 3 vendors",
"check each of these 4 files". It returns an array of results, one per spawned worker, in
spawn order. Reason over the whole array: some entries may have failed while others
succeeded. Do **not** use it when the subtasks depend on each other's output (sequence
those, or use `rhai_workflows` for real control flow), and don't fan out work that a
single delegation or a direct tool already covers.
### Async background sub-agents
Use `spawn_async_subagent` only for low-attention background work where the current user
response must not depend on the result. Good fits: best-effort memory archiving,