Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 194 additions & 60 deletions app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { apiClient } from '../../agentworld/AgentWorldShell';
import { orchestrationClient } from '../../lib/orchestration/orchestrationClient';
import { socketService } from '../../services/socketService';
import TinyPlaceOrchestrationTab from './TinyPlaceOrchestrationTab';

vi.mock('../../agentworld/AgentWorldShell', () => ({
apiClient: {
messages: { list: vi.fn() },
inbox: { list: vi.fn() },
orchestrationPairing: {
list: vi.fn(),
linkSession: vi.fn(),
Expand All @@ -18,21 +18,73 @@ vi.mock('../../agentworld/AgentWorldShell', () => ({
},
}));

vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => {
const actual =
await importOriginal<typeof import('../../lib/orchestration/orchestrationClient')>();
return {
...actual,
orchestrationClient: {
sessionsList: vi.fn(),
messagesList: vi.fn(),
sendMasterMessage: vi.fn(),
markRead: vi.fn(),
status: vi.fn(),
},
};
});

vi.mock('../../services/socketService', () => {
const socket = { on: vi.fn(), off: vi.fn() };
return { socketService: { getSocket: vi.fn(() => socket) } };
});

vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));

const messagesListMock = vi.mocked(apiClient.messages.list);
const inboxListMock = vi.mocked(apiClient.inbox.list);
const sessionsListMock = vi.mocked(orchestrationClient.sessionsList);
const messagesListMock = vi.mocked(orchestrationClient.messagesList);
const sendMasterMock = vi.mocked(orchestrationClient.sendMasterMessage);
const markReadMock = vi.mocked(orchestrationClient.markRead);
const statusMock = vi.mocked(orchestrationClient.status);

const pairingListMock = vi.mocked(apiClient.orchestrationPairing.list);
const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession);
const pairingAcceptMock = vi.mocked(apiClient.orchestrationPairing.acceptRequest);
const pairingDeclineMock = vi.mocked(apiClient.orchestrationPairing.declineRequest);
const pairingBlockMock = vi.mocked(apiClient.orchestrationPairing.blockRequest);

const getSocketMock = vi.mocked(socketService.getSocket);

const PINNED_SESSIONS = [
{
sessionId: 'master',
agentId: '@openhuman',
source: 'core',
chatKind: 'master' as const,
lastMessageAt: '2026-07-01T12:00:00.000Z',
unread: 0,
active: true,
pinned: true,
},
{
sessionId: 'subconscious',
agentId: '@openhuman',
source: 'core',
chatKind: 'subconscious' as const,
lastMessageAt: '2026-07-01T12:01:00.000Z',
unread: 0,
active: true,
pinned: true,
},
];

describe('TinyPlaceOrchestrationTab', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionsListMock.mockResolvedValue({ sessions: [...PINNED_SESSIONS] });
messagesListMock.mockResolvedValue({ messages: [] });
inboxListMock.mockResolvedValue({ items: [], unreadCount: 0, totalCount: 0 });
sendMasterMock.mockResolvedValue({ ok: true, messageId: 'm-1' });
markReadMock.mockResolvedValue({ ok: true });
statusMock.mockResolvedValue({});
pairingListMock.mockResolvedValue({
records: [],
contacts: { contacts: [] },
Expand Down Expand Up @@ -69,94 +121,162 @@ describe('TinyPlaceOrchestrationTab', () => {
});
});

it('renders pinned master and subconscious chats before session chats', async () => {
messagesListMock.mockResolvedValue({
messages: [
{
id: 'm-master',
from: 'human',
to: 'master-agent',
timestamp: '2026-07-01T12:00:00.000Z',
deviceId: 1,
type: 'agent-human',
body: 'Coordinate the next worker handoff',
},
it('renders pinned master and subconscious chats plus app sessions', async () => {
sessionsListMock.mockResolvedValue({
sessions: [
...PINNED_SESSIONS,
{
id: 'm-subconscious',
from: 'subconscious-loop',
to: 'tinyplace_agent',
timestamp: '2026-07-01T12:01:00.000Z',
deviceId: 1,
type: 'internal',
body: 'Memory synthesis finished',
},
{
id: 'm-session',
from: '@worker-alpha',
to: '@openhuman',
timestamp: '2026-07-01T12:02:00.000Z',
deviceId: 1,
type: 'session',
body: 'I asked the human master for context, then opened a worktree.',
sessionId: 'app-session-1',
sessionLabel: 'OpenHuman app session',
agentId: '@worker-alpha',
source: 'openhuman-app',
label: 'OpenHuman app session',
chatKind: 'session',
lastMessageAt: '2026-07-01T12:02:00.000Z',
unread: 0,
active: true,
pinned: false,
},
],
});

render(<TinyPlaceOrchestrationTab />);

// Pinned master appears twice: in the list button and the main header.
expect(await screen.findAllByText('tinyplaceOrchestration.master.title')).toHaveLength(2);
expect(screen.getByText('tinyplaceOrchestration.subconscious.title')).toBeInTheDocument();
expect(screen.getByText('OpenHuman app session')).toBeInTheDocument();
});

fireEvent.click(screen.getByTestId('tinyplace-chat-session:app-session-1'));
it('loads and renders messages for the opened chat', async () => {
sessionsListMock.mockResolvedValue({
sessions: [
...PINNED_SESSIONS,
{
sessionId: 'app-session-1',
agentId: '@worker-alpha',
source: 'openhuman-app',
label: 'OpenHuman app session',
chatKind: 'session',
lastMessageAt: '2026-07-01T12:02:00.000Z',
unread: 0,
active: true,
pinned: false,
},
],
});
messagesListMock.mockImplementation(async ({ chat }) => {
if (chat === 'app-session-1') {
return {
messages: [
{
id: 'm-session',
agentId: '@worker-alpha',
sessionId: 'app-session-1',
chatKind: 'session' as const,
role: '@worker-alpha',
body: 'I opened a worktree and asked the master for context.',
timestamp: '2026-07-01T12:02:00.000Z',
seq: 1,
},
],
};
}
return { messages: [] };
});

render(<TinyPlaceOrchestrationTab />);

fireEvent.click(await screen.findByTestId('tinyplace-chat-app-session-1'));

expect(
within(await screen.findByTestId('tinyplace-chat-messages')).getByText(
'I asked the human master for context, then opened a worktree.'
'I opened a worktree and asked the master for context.'
)
).toBeInTheDocument();
await waitFor(() =>
expect(messagesListMock).toHaveBeenCalledWith(
expect.objectContaining({ chat: 'app-session-1' })
)
);
});

it('adds unread inbox sessions and marks them active', async () => {
inboxListMock.mockResolvedValue({
items: [
it('marks a chat read when it is opened', async () => {
sessionsListMock.mockResolvedValue({
sessions: [
...PINNED_SESSIONS,
{
itemId: 'inbox-1',
type: 'dm',
status: 'unread',
priority: 'normal',
timestamp: '2026-07-01T12:03:00.000Z',
subject: 'Worker update',
summary: 'The subagent is waiting on a decision.',
from: '@worker-beta',
sessionId: 'app-session-1',
agentId: '@worker-alpha',
source: 'openhuman-app',
label: 'OpenHuman app session',
chatKind: 'session',
lastMessageAt: '2026-07-01T12:02:00.000Z',
unread: 3,
active: true,
pinned: false,
},
],
unreadCount: 1,
totalCount: 1,
});

render(<TinyPlaceOrchestrationTab />);

expect(await screen.findByText('@worker-beta')).toBeInTheDocument();
expect(screen.getByText('The subagent is waiting on a decision.')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('tinyplaceOrchestration.active')).toBeInTheDocument();
fireEvent.click(await screen.findByTestId('tinyplace-chat-app-session-1'));

await waitFor(() => expect(markReadMock).toHaveBeenCalledWith('app-session-1'));
});

it('surfaces load errors and retries', async () => {
messagesListMock.mockRejectedValueOnce(new Error('rpc failed'));
it('sends a master message and optimistically appends it', async () => {
// Hold the send promise open so the optimistic append is observable before
// the success reconcile refetch replaces it.
let resolveSend: (() => void) | undefined;
sendMasterMock.mockImplementation(
() =>
new Promise(res => {
resolveSend = () => res({ ok: true, messageId: 'm-1' });
})
);

render(<TinyPlaceOrchestrationTab />);

expect(await screen.findByText(/tinyplaceOrchestration.failedToLoad/)).toBeInTheDocument();
expect(screen.getByText(/rpc failed/)).toBeInTheDocument();
const input = await screen.findByTestId('tinyplace-master-composer-input');
fireEvent.change(input, { target: { value: 'Coordinate the next handoff' } });
fireEvent.click(screen.getByTestId('tinyplace-master-composer-send'));

fireEvent.click(screen.getByText('common.retry'));
// Optimistic append renders immediately in the message pane (send pending).
expect(
within(await screen.findByTestId('tinyplace-chat-messages')).getByText(
'Coordinate the next handoff'
)
).toBeInTheDocument();
await waitFor(() =>
expect(sendMasterMock).toHaveBeenCalledWith({ body: 'Coordinate the next handoff' })
);

await waitFor(() => expect(messagesListMock).toHaveBeenCalledTimes(2));
expect(await screen.findByText('tinyplaceOrchestration.noMessages')).toBeInTheDocument();
resolveSend?.();

// Input clears on success.
await waitFor(() => expect((input as HTMLInputElement).value).toBe(''));
});

it('refetches when an orchestration:message socket event fires', async () => {
let handler: ((payload: unknown) => void) | undefined;
const socket = {
on: vi.fn((event: string, cb: (payload: unknown) => void) => {
if (event === 'orchestration:message') handler = cb;
}),
off: vi.fn(),
};
getSocketMock.mockReturnValue(socket as never);

render(<TinyPlaceOrchestrationTab />);

await waitFor(() => expect(sessionsListMock).toHaveBeenCalled());
const initialCalls = sessionsListMock.mock.calls.length;
expect(handler).toBeDefined();

handler?.({ agentId: '@worker-alpha', sessionId: 'master', chatKind: 'master' });

await waitFor(() => expect(sessionsListMock.mock.calls.length).toBeGreaterThan(initialCalls));
});

it('requests a contact edge for a pasted session identity', async () => {
Expand Down Expand Up @@ -203,4 +323,18 @@ describe('TinyPlaceOrchestrationTab', () => {

await waitFor(() => expect(pairingAcceptMock).toHaveBeenCalledWith('@worker-pending'));
});

it('surfaces load errors and retries', async () => {
sessionsListMock.mockRejectedValueOnce(new Error('rpc failed'));

render(<TinyPlaceOrchestrationTab />);

expect(await screen.findByText(/tinyplaceOrchestration.failedToLoad/)).toBeInTheDocument();
expect(screen.getByText(/rpc failed/)).toBeInTheDocument();

fireEvent.click(screen.getByText('common.retry'));

await waitFor(() => expect(sessionsListMock).toHaveBeenCalledTimes(2));
expect(await screen.findByText('tinyplaceOrchestration.noMessages')).toBeInTheDocument();
});
});
Loading
Loading