mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
feat(orchestration): instance-shaped session model + harness roster components (#4589)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import HarnessGlyph, { type GlyphKind } from './HarnessGlyph';
|
||||
|
||||
describe('HarnessGlyph', () => {
|
||||
it.each<[GlyphKind, string]>([
|
||||
['claude', 'C'],
|
||||
['codex', 'Cx'],
|
||||
['gemini', 'G'],
|
||||
['openhuman', 'OH'],
|
||||
])('renders the %s mark', (harness, label) => {
|
||||
render(<HarnessGlyph harness={harness} />);
|
||||
const glyph = screen.getByTestId('harness-glyph');
|
||||
expect(glyph).toHaveAttribute('data-harness', harness);
|
||||
expect(glyph).toHaveTextContent(label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* HarnessGlyph — a small brand mark for the agent harness driving an instance
|
||||
* (Claude / Codex / Gemini), plus an OpenHuman mark for internal windows. Used
|
||||
* as the leading glyph in roster rows.
|
||||
*
|
||||
* The colors here are deliberate brand/identity hues (not surface chrome), so
|
||||
* they use literal palette classes per the project's "meaningful content color"
|
||||
* guidance rather than semantic tokens.
|
||||
*/
|
||||
import type { HarnessType } from '../../lib/orchestration/orchestrationClient';
|
||||
|
||||
export type GlyphKind = HarnessType | 'openhuman';
|
||||
|
||||
export interface HarnessGlyphProps {
|
||||
harness: GlyphKind;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GLYPH: Record<GlyphKind, { label: string; tone: string }> = {
|
||||
claude: { label: 'C', tone: 'bg-[#c96442] text-white' },
|
||||
codex: { label: 'Cx', tone: 'bg-content text-surface' },
|
||||
gemini: { label: 'G', tone: 'bg-ocean-500 text-white' },
|
||||
openhuman: { label: 'OH', tone: 'bg-sage-500 text-white' },
|
||||
};
|
||||
|
||||
export default function HarnessGlyph({
|
||||
harness,
|
||||
className,
|
||||
}: HarnessGlyphProps): React.ReactElement {
|
||||
const { label, tone } = GLYPH[harness];
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="harness-glyph"
|
||||
data-harness={harness}
|
||||
className={`flex h-6 w-6 flex-none items-center justify-center rounded-md font-mono text-[11px] font-bold ${tone} ${className ?? ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SessionSummary } from '../../lib/orchestration/orchestrationClient';
|
||||
import InstanceCard from './InstanceCard';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
function session(over: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
sessionId: 'w1',
|
||||
agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh',
|
||||
source: 'claude',
|
||||
harnessType: 'claude',
|
||||
status: 'idle',
|
||||
currentTask: 'drafting hub cards',
|
||||
chatKind: 'session',
|
||||
lastMessageAt: '2026-07-06T00:00:00Z',
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('InstanceCard', () => {
|
||||
it('renders the harness glyph, status dot, task and shortened address', () => {
|
||||
render(<InstanceCard session={session()} />);
|
||||
expect(screen.getByTestId('harness-glyph')).toHaveAttribute('data-harness', 'claude');
|
||||
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('data-status', 'idle');
|
||||
expect(screen.getByText('drafting hub cards')).toBeInTheDocument();
|
||||
expect(screen.getByText('6wNaBJ…QbHSh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers a resolved @handle over the address', () => {
|
||||
render(<InstanceCard session={session()} handle="claudebot" />);
|
||||
expect(screen.getByText('@claudebot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the unread pill only when unread > 0 and fires onSelect', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { rerender } = render(<InstanceCard session={session()} onSelect={onSelect} />);
|
||||
expect(screen.queryByTestId('instance-card-unread')).toBeNull();
|
||||
|
||||
rerender(<InstanceCard session={session({ unread: 3 })} onSelect={onSelect} />);
|
||||
expect(screen.getByTestId('instance-card-unread')).toHaveTextContent('3');
|
||||
|
||||
fireEvent.click(screen.getByTestId('instance-card-w1'));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('falls back to the OpenHuman glyph when no harness is set', () => {
|
||||
render(<InstanceCard session={session({ harnessType: undefined, source: 'user_created' })} />);
|
||||
expect(screen.getByTestId('harness-glyph')).toHaveAttribute('data-harness', 'openhuman');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* InstanceCard — one roster row for an agent instance: harness glyph + status
|
||||
* dot + identity + one-line current task + unread pill.
|
||||
*
|
||||
* Presentational only; the parent supplies the {@link SessionSummary} and an
|
||||
* optional resolved `@handle` (the raw address is the fallback).
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { InstanceStatus, SessionSummary } from '../../lib/orchestration/orchestrationClient';
|
||||
import HarnessGlyph, { type GlyphKind } from './HarnessGlyph';
|
||||
import InstanceStatusDot from './InstanceStatusDot';
|
||||
|
||||
export interface InstanceCardProps {
|
||||
session: SessionSummary;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
/** Resolved `@handle` for the peer, if known (address is the fallback). */
|
||||
handle?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_LABEL_KEY: Record<InstanceStatus, string> = {
|
||||
running: 'tinyplaceOrchestration.status.running',
|
||||
idle: 'tinyplaceOrchestration.status.idle',
|
||||
'waiting-approval': 'tinyplaceOrchestration.status.waitingApproval',
|
||||
errored: 'tinyplaceOrchestration.status.errored',
|
||||
stopped: 'tinyplaceOrchestration.status.stopped',
|
||||
};
|
||||
|
||||
function shortAddress(address: string): string {
|
||||
if (address.length <= 14) return address;
|
||||
return `${address.slice(0, 6)}…${address.slice(-5)}`;
|
||||
}
|
||||
|
||||
export default function InstanceCard({
|
||||
session,
|
||||
selected,
|
||||
onSelect,
|
||||
handle,
|
||||
}: InstanceCardProps): React.ReactElement {
|
||||
const { t } = useT();
|
||||
const glyph: GlyphKind = session.harnessType ?? 'openhuman';
|
||||
const identity = handle ? `@${handle}` : (session.label ?? shortAddress(session.agentId));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`instance-card-${session.sessionId}`}
|
||||
data-selected={selected ? 'true' : 'false'}
|
||||
onClick={onSelect}
|
||||
className={`flex w-full items-center gap-3 border-l-2 px-3 py-2 text-left transition hover:bg-surface-hover ${
|
||||
selected ? 'border-ocean-500 bg-surface-muted' : 'border-transparent'
|
||||
}`}>
|
||||
<HarnessGlyph harness={glyph} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<InstanceStatusDot status={session.status} label={t(STATUS_LABEL_KEY[session.status])} />
|
||||
<span className="truncate text-xs font-semibold text-content">{identity}</span>
|
||||
</span>
|
||||
{session.currentTask ? (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-content-muted">
|
||||
{session.currentTask}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{session.unread > 0 ? (
|
||||
<span
|
||||
data-testid="instance-card-unread"
|
||||
className="flex-none rounded-full bg-ocean-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
|
||||
{session.unread}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InstanceStatus } from '../../lib/orchestration/orchestrationClient';
|
||||
import InstanceStatusDot from './InstanceStatusDot';
|
||||
|
||||
describe('InstanceStatusDot', () => {
|
||||
it.each<InstanceStatus>(['running', 'idle', 'waiting-approval', 'errored', 'stopped'])(
|
||||
'tags the %s status and pulses only when running',
|
||||
status => {
|
||||
render(<InstanceStatusDot status={status} label={status} />);
|
||||
const dot = screen.getByTestId('instance-status-dot');
|
||||
expect(dot).toHaveAttribute('data-status', status);
|
||||
expect(dot.className.includes('animate-pulse')).toBe(status === 'running');
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the status value as the accessible name when no label is given', () => {
|
||||
render(<InstanceStatusDot status="errored" />);
|
||||
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('aria-label', 'errored');
|
||||
});
|
||||
|
||||
it('prefers a provided translated label', () => {
|
||||
render(<InstanceStatusDot status="idle" label="Inactivo" />);
|
||||
expect(screen.getByTestId('instance-status-dot')).toHaveAttribute('aria-label', 'Inactivo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* InstanceStatusDot — the core roster primitive: one colored dot that encodes an
|
||||
* agent instance's status at a glance, scannable across many rows.
|
||||
*
|
||||
* Five states (color + motion), matching the core's {@link InstanceStatus}:
|
||||
* running (ocean, pulsing) · idle (sage) · waiting-approval (amber) ·
|
||||
* errored (coral) · stopped (faint). The core derives only idle/stopped today;
|
||||
* the other three are wired ahead of the attention-queue / run-state work.
|
||||
*
|
||||
* Presentational only. Pass a translated `label` for the accessible name.
|
||||
*/
|
||||
import type { InstanceStatus } from '../../lib/orchestration/orchestrationClient';
|
||||
|
||||
export interface InstanceStatusDotProps {
|
||||
status: InstanceStatus;
|
||||
/** Accessible label (already translated by the caller). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const TONE: Record<InstanceStatus, string> = {
|
||||
running: 'bg-ocean-500 animate-pulse',
|
||||
idle: 'bg-sage-500',
|
||||
'waiting-approval': 'bg-amber-500',
|
||||
errored: 'bg-coral-500',
|
||||
stopped: 'bg-content-faint',
|
||||
};
|
||||
|
||||
export default function InstanceStatusDot({
|
||||
status,
|
||||
label,
|
||||
}: InstanceStatusDotProps): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label ?? status}
|
||||
title={label ?? status}
|
||||
data-testid="instance-status-dot"
|
||||
data-status={status}
|
||||
className={`inline-block h-2 w-2 flex-none rounded-full ${TONE[status]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const PINNED_SESSIONS = [
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: true,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
{
|
||||
sessionId: 'subconscious',
|
||||
@@ -79,6 +80,7 @@ const PINNED_SESSIONS = [
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: true,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -96,6 +98,7 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
});
|
||||
messagesListMock.mockResolvedValue({ messages: [] });
|
||||
@@ -175,6 +178,7 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -201,6 +205,7 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -254,6 +259,7 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
unread: 3,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -499,6 +505,7 @@ describe('TinyPlaceOrchestrationTab', () => {
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
status: 'idle' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SessionSummary } from '../../lib/orchestration/orchestrationClient';
|
||||
import TinyPlaceRoster from './TinyPlaceRoster';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
function session(over: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
sessionId: 's',
|
||||
agentId: '@peer',
|
||||
source: 'claude',
|
||||
harnessType: 'claude',
|
||||
status: 'idle',
|
||||
chatKind: 'session',
|
||||
lastMessageAt: '2026-07-06T00:00:00Z',
|
||||
unread: 0,
|
||||
active: true,
|
||||
pinned: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TinyPlaceRoster', () => {
|
||||
it('shows the empty state when there are no instance sessions', () => {
|
||||
// Pinned windows are not instances and must not count.
|
||||
const pinned = session({ sessionId: 'master', chatKind: 'master', pinned: true });
|
||||
render(<TinyPlaceRoster sessions={[pinned]} />);
|
||||
expect(screen.getByTestId('tinyplace-roster-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups instances by harness and lists an Other group for harness-less sessions', () => {
|
||||
const sessions = [
|
||||
session({ sessionId: 'c1', harnessType: 'claude' }),
|
||||
session({ sessionId: 'x1', harnessType: 'codex', source: 'codex' }),
|
||||
session({ sessionId: 'u1', harnessType: undefined, source: 'user_created' }),
|
||||
];
|
||||
render(<TinyPlaceRoster sessions={sessions} />);
|
||||
expect(screen.getByText('Claude')).toBeInTheDocument();
|
||||
expect(screen.getByText('Codex')).toBeInTheDocument();
|
||||
// Harness-less session lands under the (translated) Other group; no empty Gemini group.
|
||||
expect(screen.getByText('tinyplaceOrchestration.roster.other')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Gemini')).toBeNull();
|
||||
expect(screen.getByTestId('instance-card-c1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('instance-card-u1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected instance and forwards selection', () => {
|
||||
const onSelect = vi.fn();
|
||||
const sessions = [session({ sessionId: 'c1' }), session({ sessionId: 'c2' })];
|
||||
render(<TinyPlaceRoster sessions={sessions} selectedId="c1" onSelect={onSelect} />);
|
||||
expect(screen.getByTestId('instance-card-c1')).toHaveAttribute('data-selected', 'true');
|
||||
expect(screen.getByTestId('instance-card-c2')).toHaveAttribute('data-selected', 'false');
|
||||
fireEvent.click(screen.getByTestId('instance-card-c2'));
|
||||
expect(onSelect).toHaveBeenCalledWith('c2');
|
||||
});
|
||||
|
||||
it('passes resolved handles down to the cards', () => {
|
||||
render(
|
||||
<TinyPlaceRoster
|
||||
sessions={[session({ sessionId: 'c1', agentId: '@peer' })]}
|
||||
handles={{ '@peer': 'claudebot' }}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId('instance-card-c1');
|
||||
expect(within(card).getByText('@claudebot')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* TinyPlaceRoster — the instance roster: external agent sessions grouped by
|
||||
* harness (Claude / Codex / Gemini, then an "Other" catch-all), each a
|
||||
* selectable {@link InstanceCard}. Pinned master/subconscious windows are not
|
||||
* instances and are excluded here.
|
||||
*
|
||||
* Presentational only; the parent passes the session list, selection, and an
|
||||
* optional address→@handle map.
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { HarnessType, SessionSummary } from '../../lib/orchestration/orchestrationClient';
|
||||
import InstanceCard from './InstanceCard';
|
||||
|
||||
export interface TinyPlaceRosterProps {
|
||||
sessions: SessionSummary[];
|
||||
selectedId?: string;
|
||||
onSelect?: (sessionId: string) => void;
|
||||
/** Resolved address → `@handle` map (best-effort; address is the fallback). */
|
||||
handles?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
// Grouped in this order; brand names are identity, not translated UI copy.
|
||||
const HARNESS_GROUPS: Array<{ key: HarnessType; label: string }> = [
|
||||
{ key: 'claude', label: 'Claude' },
|
||||
{ key: 'codex', label: 'Codex' },
|
||||
{ key: 'gemini', label: 'Gemini' },
|
||||
];
|
||||
|
||||
export default function TinyPlaceRoster({
|
||||
sessions,
|
||||
selectedId,
|
||||
onSelect,
|
||||
handles,
|
||||
}: TinyPlaceRosterProps): React.ReactElement {
|
||||
const { t } = useT();
|
||||
|
||||
// Instances are the non-pinned session windows.
|
||||
const instances = sessions.filter(s => !s.pinned && s.chatKind === 'session');
|
||||
|
||||
const byHarness = (harness: HarnessType): SessionSummary[] =>
|
||||
instances.filter(s => s.harnessType === harness);
|
||||
const ungrouped = instances.filter(s => !s.harnessType);
|
||||
|
||||
const groups = [
|
||||
...HARNESS_GROUPS.map(g => ({ label: g.label, rows: byHarness(g.key) })),
|
||||
{ label: t('tinyplaceOrchestration.roster.other'), rows: ungrouped },
|
||||
].filter(g => g.rows.length > 0);
|
||||
|
||||
return (
|
||||
<section data-testid="tinyplace-roster" className="min-w-0">
|
||||
<h4 className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{t('tinyplaceOrchestration.roster.instances')}
|
||||
</h4>
|
||||
{instances.length === 0 ? (
|
||||
<p
|
||||
data-testid="tinyplace-roster-empty"
|
||||
className="px-3 py-4 text-[11px] text-content-faint">
|
||||
{t('tinyplaceOrchestration.roster.empty')}
|
||||
</p>
|
||||
) : (
|
||||
groups.map(group => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-content-faint">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.rows.map(session => (
|
||||
<InstanceCard
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
selected={session.sessionId === selectedId}
|
||||
handle={handles?.[session.agentId]}
|
||||
onSelect={onSelect ? () => onSelect(session.sessionId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -288,6 +288,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'إرسال',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'فشل إرسال الرسالة',
|
||||
'tinyplaceOrchestration.steering.label': 'التوجيه',
|
||||
'tinyplaceOrchestration.roster.instances': 'مثيلات',
|
||||
'tinyplaceOrchestration.roster.empty': 'لا توجد مثيلات وكيل بعد',
|
||||
'tinyplaceOrchestration.roster.other': 'أخرى',
|
||||
'tinyplaceOrchestration.status.running': 'قيد التشغيل',
|
||||
'tinyplaceOrchestration.status.idle': 'خامل',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'في انتظار الموافقة',
|
||||
'tinyplaceOrchestration.status.errored': 'خطأ',
|
||||
'tinyplaceOrchestration.status.stopped': 'متوقف',
|
||||
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
|
||||
'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.',
|
||||
'common.cancel': 'إلغاء',
|
||||
|
||||
@@ -295,6 +295,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'পাঠান',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'বার্তা পাঠাতে ব্যর্থ',
|
||||
'tinyplaceOrchestration.steering.label': 'নির্দেশনা',
|
||||
'tinyplaceOrchestration.roster.instances': 'ইনস্ট্যান্স',
|
||||
'tinyplaceOrchestration.roster.empty': 'এখনও কোনো এজেন্ট ইনস্ট্যান্স নেই',
|
||||
'tinyplaceOrchestration.roster.other': 'অন্যান্য',
|
||||
'tinyplaceOrchestration.status.running': 'চলছে',
|
||||
'tinyplaceOrchestration.status.idle': 'নিষ্ক্রিয়',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'অনুমোদনের অপেক্ষায়',
|
||||
'tinyplaceOrchestration.status.errored': 'ত্রুটিপূর্ণ',
|
||||
'tinyplaceOrchestration.status.stopped': 'থেমে গেছে',
|
||||
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
|
||||
'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
|
||||
'common.cancel': 'বাতিল',
|
||||
|
||||
@@ -302,6 +302,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Senden',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Nachricht konnte nicht gesendet werden',
|
||||
'tinyplaceOrchestration.steering.label': 'Steuerung',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instanzen',
|
||||
'tinyplaceOrchestration.roster.empty': 'Noch keine Agent-Instanzen',
|
||||
'tinyplaceOrchestration.roster.other': 'Andere',
|
||||
'tinyplaceOrchestration.status.running': 'Läuft',
|
||||
'tinyplaceOrchestration.status.idle': 'Inaktiv',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Warten auf Freigabe',
|
||||
'tinyplaceOrchestration.status.errored': 'Fehler',
|
||||
'tinyplaceOrchestration.status.stopped': 'Gestoppt',
|
||||
'brain.empty': 'Dein Gehirn ist noch leer – verbinde eine Quelle, um Speicher aufzubauen.',
|
||||
'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.',
|
||||
'common.cancel': 'Abbrechen',
|
||||
|
||||
@@ -4223,6 +4223,14 @@ const en: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Send',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Failed to send message',
|
||||
'tinyplaceOrchestration.steering.label': 'Steering',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instances',
|
||||
'tinyplaceOrchestration.roster.empty': 'No agent instances yet',
|
||||
'tinyplaceOrchestration.roster.other': 'Other',
|
||||
'tinyplaceOrchestration.status.running': 'Running',
|
||||
'tinyplaceOrchestration.status.idle': 'Idle',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Waiting for approval',
|
||||
'tinyplaceOrchestration.status.errored': 'Errored',
|
||||
'tinyplaceOrchestration.status.stopped': 'Stopped',
|
||||
'intelligence.teams.subtitle': 'Coordinated agent teams and the tasks they share.',
|
||||
'intelligence.teams.loading': 'Loading teams…',
|
||||
'intelligence.teams.failedToLoad': 'Failed to load teams',
|
||||
|
||||
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Enviar',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'No se pudo enviar el mensaje',
|
||||
'tinyplaceOrchestration.steering.label': 'Dirección',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instancias',
|
||||
'tinyplaceOrchestration.roster.empty': 'Aún no hay instancias de agente',
|
||||
'tinyplaceOrchestration.roster.other': 'Otros',
|
||||
'tinyplaceOrchestration.status.running': 'En ejecución',
|
||||
'tinyplaceOrchestration.status.idle': 'Inactivo',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Esperando aprobación',
|
||||
'tinyplaceOrchestration.status.errored': 'Con error',
|
||||
'tinyplaceOrchestration.status.stopped': 'Detenido',
|
||||
'brain.empty':
|
||||
'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.',
|
||||
'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.',
|
||||
|
||||
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Envoyer',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Échec de l’envoi du message',
|
||||
'tinyplaceOrchestration.steering.label': 'Pilotage',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instances',
|
||||
'tinyplaceOrchestration.roster.empty': 'Aucune instance d’agent',
|
||||
'tinyplaceOrchestration.roster.other': 'Autre',
|
||||
'tinyplaceOrchestration.status.running': 'En cours',
|
||||
'tinyplaceOrchestration.status.idle': 'Inactif',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'En attente d’approbation',
|
||||
'tinyplaceOrchestration.status.errored': 'En erreur',
|
||||
'tinyplaceOrchestration.status.stopped': 'Arrêté',
|
||||
'brain.empty':
|
||||
'Votre cerveau est vide pour l’instant — connectez une source pour commencer à constituer votre mémoire.',
|
||||
'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.',
|
||||
|
||||
@@ -295,6 +295,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'भेजें',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'संदेश भेजने में विफल',
|
||||
'tinyplaceOrchestration.steering.label': 'मार्गदर्शन',
|
||||
'tinyplaceOrchestration.roster.instances': 'इंस्टेंस',
|
||||
'tinyplaceOrchestration.roster.empty': 'अभी तक कोई एजेंट इंस्टेंस नहीं',
|
||||
'tinyplaceOrchestration.roster.other': 'अन्य',
|
||||
'tinyplaceOrchestration.status.running': 'चल रहा है',
|
||||
'tinyplaceOrchestration.status.idle': 'निष्क्रिय',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'अनुमोदन की प्रतीक्षा में',
|
||||
'tinyplaceOrchestration.status.errored': 'त्रुटि',
|
||||
'tinyplaceOrchestration.status.stopped': 'रुका हुआ',
|
||||
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
|
||||
'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।',
|
||||
'common.cancel': 'रद्द करें',
|
||||
|
||||
@@ -296,6 +296,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Kirim',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Gagal mengirim pesan',
|
||||
'tinyplaceOrchestration.steering.label': 'Pengarahan',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instansi',
|
||||
'tinyplaceOrchestration.roster.empty': 'Belum ada instansi agen',
|
||||
'tinyplaceOrchestration.roster.other': 'Lainnya',
|
||||
'tinyplaceOrchestration.status.running': 'Berjalan',
|
||||
'tinyplaceOrchestration.status.idle': 'Diam',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Menunggu persetujuan',
|
||||
'tinyplaceOrchestration.status.errored': 'Bermasalah',
|
||||
'tinyplaceOrchestration.status.stopped': 'Berhenti',
|
||||
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
|
||||
'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.',
|
||||
'common.cancel': 'Batal',
|
||||
|
||||
@@ -299,6 +299,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Invia',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Invio del messaggio non riuscito',
|
||||
'tinyplaceOrchestration.steering.label': 'Direzione',
|
||||
'tinyplaceOrchestration.roster.instances': 'Istanze',
|
||||
'tinyplaceOrchestration.roster.empty': 'Nessuna istanza agente',
|
||||
'tinyplaceOrchestration.roster.other': 'Altro',
|
||||
'tinyplaceOrchestration.status.running': 'In esecuzione',
|
||||
'tinyplaceOrchestration.status.idle': 'Inattivo',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'In attesa di approvazione',
|
||||
'tinyplaceOrchestration.status.errored': 'In errore',
|
||||
'tinyplaceOrchestration.status.stopped': 'Arrestato',
|
||||
'brain.empty':
|
||||
'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.',
|
||||
'brain.error': 'Impossibile caricare il tuo cervello. Riprova.',
|
||||
|
||||
@@ -292,6 +292,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': '보내기',
|
||||
'tinyplaceOrchestration.composer.sendFailed': '메시지를 보내지 못했습니다',
|
||||
'tinyplaceOrchestration.steering.label': '조종',
|
||||
'tinyplaceOrchestration.roster.instances': '인스턴스',
|
||||
'tinyplaceOrchestration.roster.empty': '아직 에이전트 인스턴스가 없습니다',
|
||||
'tinyplaceOrchestration.roster.other': '기타',
|
||||
'tinyplaceOrchestration.status.running': '실행 중',
|
||||
'tinyplaceOrchestration.status.idle': '유휴',
|
||||
'tinyplaceOrchestration.status.waitingApproval': '승인 대기 중',
|
||||
'tinyplaceOrchestration.status.errored': '오류',
|
||||
'tinyplaceOrchestration.status.stopped': '중지됨',
|
||||
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
|
||||
'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.',
|
||||
'common.cancel': '취소',
|
||||
|
||||
@@ -300,6 +300,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Wyślij',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Nie udało się wysłać wiadomości',
|
||||
'tinyplaceOrchestration.steering.label': 'Sterowanie',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instancje',
|
||||
'tinyplaceOrchestration.roster.empty': 'Brak instancji agentów',
|
||||
'tinyplaceOrchestration.roster.other': 'Inne',
|
||||
'tinyplaceOrchestration.status.running': 'Działa',
|
||||
'tinyplaceOrchestration.status.idle': 'Bezczynny',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Oczekuje na zatwierdzenie',
|
||||
'tinyplaceOrchestration.status.errored': 'Błąd',
|
||||
'tinyplaceOrchestration.status.stopped': 'Zatrzymany',
|
||||
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
|
||||
'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.',
|
||||
'common.cancel': 'Anuluj',
|
||||
|
||||
@@ -298,6 +298,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Enviar',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Falha ao enviar mensagem',
|
||||
'tinyplaceOrchestration.steering.label': 'Direção',
|
||||
'tinyplaceOrchestration.roster.instances': 'Instâncias',
|
||||
'tinyplaceOrchestration.roster.empty': 'Nenhuma instância de agente ainda',
|
||||
'tinyplaceOrchestration.roster.other': 'Outros',
|
||||
'tinyplaceOrchestration.status.running': 'Em execução',
|
||||
'tinyplaceOrchestration.status.idle': 'Inativo',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Aguardando aprovação',
|
||||
'tinyplaceOrchestration.status.errored': 'Com erro',
|
||||
'tinyplaceOrchestration.status.stopped': 'Parado',
|
||||
'brain.empty':
|
||||
'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.',
|
||||
'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.',
|
||||
|
||||
@@ -300,6 +300,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': 'Отправить',
|
||||
'tinyplaceOrchestration.composer.sendFailed': 'Не удалось отправить сообщение',
|
||||
'tinyplaceOrchestration.steering.label': 'Управление',
|
||||
'tinyplaceOrchestration.roster.instances': 'Экземпляры',
|
||||
'tinyplaceOrchestration.roster.empty': 'Пока нет экземпляров агентов',
|
||||
'tinyplaceOrchestration.roster.other': 'Другое',
|
||||
'tinyplaceOrchestration.status.running': 'Выполняется',
|
||||
'tinyplaceOrchestration.status.idle': 'Простаивает',
|
||||
'tinyplaceOrchestration.status.waitingApproval': 'Ожидает подтверждения',
|
||||
'tinyplaceOrchestration.status.errored': 'Ошибка',
|
||||
'tinyplaceOrchestration.status.stopped': 'Остановлен',
|
||||
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
|
||||
'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -276,6 +276,14 @@ const messages: TranslationMap = {
|
||||
'tinyplaceOrchestration.composer.send': '发送',
|
||||
'tinyplaceOrchestration.composer.sendFailed': '消息发送失败',
|
||||
'tinyplaceOrchestration.steering.label': '引导',
|
||||
'tinyplaceOrchestration.roster.instances': '实例',
|
||||
'tinyplaceOrchestration.roster.empty': '暂无代理实例',
|
||||
'tinyplaceOrchestration.roster.other': '其他',
|
||||
'tinyplaceOrchestration.status.running': '运行中',
|
||||
'tinyplaceOrchestration.status.idle': '空闲',
|
||||
'tinyplaceOrchestration.status.waitingApproval': '等待批准',
|
||||
'tinyplaceOrchestration.status.errored': '出错',
|
||||
'tinyplaceOrchestration.status.stopped': '已停止',
|
||||
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
|
||||
'brain.error': '无法加载你的大脑,请重试。',
|
||||
'common.cancel': '取消',
|
||||
|
||||
@@ -20,10 +20,27 @@ export { PaymentRequiredError };
|
||||
|
||||
export type OrchestrationChatKind = 'master' | 'subconscious' | 'session';
|
||||
|
||||
/** External agent harness that emits a session (drives the roster grouping). */
|
||||
export type HarnessType = 'claude' | 'codex' | 'gemini';
|
||||
|
||||
/**
|
||||
* Coarse instance status for the roster dot. Peer instances carry no true
|
||||
* run-state yet, so the core derives only `idle` / `stopped` today; the
|
||||
* remaining states are modelled here (and by `InstanceStatusDot`) for the
|
||||
* attention-queue and run-state follow-ups.
|
||||
*/
|
||||
export type InstanceStatus = 'running' | 'idle' | 'waiting-approval' | 'errored' | 'stopped';
|
||||
|
||||
export interface SessionSummary {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
source: string;
|
||||
/** Emitting harness when this is an external instance; absent for master/subconscious/user-created. */
|
||||
harnessType?: HarnessType;
|
||||
/** Coarse status for the roster dot (see {@link InstanceStatus}). */
|
||||
status: InstanceStatus;
|
||||
/** One-line current activity (latest message preview) for the roster. */
|
||||
currentTask?: string;
|
||||
label?: string;
|
||||
workspace?: string;
|
||||
chatKind: OrchestrationChatKind;
|
||||
|
||||
@@ -132,6 +132,15 @@ struct SessionSummary {
|
||||
session_id: String,
|
||||
agent_id: String,
|
||||
source: String,
|
||||
/// The emitting harness (claude/codex/gemini) when this is an external agent
|
||||
/// instance; absent for the pinned master/subconscious/user-created windows.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
harness_type: Option<String>,
|
||||
/// Coarse instance status for the roster status dot (see `derive_status`).
|
||||
status: String,
|
||||
/// One-line current activity (latest message preview) for the roster.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
current_task: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -196,14 +205,60 @@ fn is_active(last_message_at: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(session: OrchestrationSession, unread: i64, pinned: bool) -> SessionSummary {
|
||||
/// The harness provider for a session, when its `source` names one. Session
|
||||
/// windows persist the emitting harness (claude/codex/gemini) in `source` (see
|
||||
/// `ingest.rs`); the sentinel windows (master/subconscious/user_created/
|
||||
/// orchestration) carry no harness and yield `None`.
|
||||
fn harness_type_for(source: &str) -> Option<String> {
|
||||
matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string())
|
||||
}
|
||||
|
||||
/// Coarse instance status for the roster dot, derived from activity. Peer
|
||||
/// instances carry no true run-state yet, so today an instance is `idle` when it
|
||||
/// has recent traffic and `stopped` otherwise. The richer
|
||||
/// running/waiting-approval/errored states are reserved for the attention-queue
|
||||
/// and run-state follow-ups; the renderer's `InstanceStatusDot` already models
|
||||
/// all five.
|
||||
fn derive_status(active: bool) -> &'static str {
|
||||
if active {
|
||||
"idle"
|
||||
} else {
|
||||
"stopped"
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line, UTF-8-safe preview of a message body for the roster task line.
|
||||
/// Truncates on a char boundary and reserves room for the ellipsis so the result
|
||||
/// never exceeds `MAX` chars (avoids the byte-slice panics noted in the codebase).
|
||||
fn task_preview(body: &str) -> String {
|
||||
const MAX: usize = 80;
|
||||
let trimmed = body.trim();
|
||||
if trimmed.chars().count() <= MAX {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let mut out: String = trimmed.chars().take(MAX - 1).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
fn summarize(
|
||||
session: OrchestrationSession,
|
||||
unread: i64,
|
||||
pinned: bool,
|
||||
current_task: Option<String>,
|
||||
) -> SessionSummary {
|
||||
let chat_kind = chat_kind_for_session(&session.session_id);
|
||||
let active = pinned || is_active(&session.last_message_at);
|
||||
let harness_type = harness_type_for(&session.source);
|
||||
let status = derive_status(active).to_string();
|
||||
SessionSummary {
|
||||
chat_kind: chat_kind.as_str().to_string(),
|
||||
active,
|
||||
unread,
|
||||
pinned,
|
||||
harness_type,
|
||||
status,
|
||||
current_task,
|
||||
session_id: session.session_id,
|
||||
agent_id: session.agent_id,
|
||||
source: session.source,
|
||||
@@ -231,7 +286,15 @@ fn handle_sessions_list(_params: Map<String, Value>) -> ControllerFuture {
|
||||
_ => {}
|
||||
}
|
||||
let pinned = matches!(session.session_id.as_str(), "master" | "subconscious");
|
||||
out.push(summarize(session, unread, pinned));
|
||||
// Roster task line: latest message preview for real instance
|
||||
// windows; the pinned windows don't need one.
|
||||
let current_task = if pinned {
|
||||
None
|
||||
} else {
|
||||
store::latest_message_preview(conn, &session.agent_id, &session.session_id)?
|
||||
.map(|body| task_preview(&body))
|
||||
};
|
||||
out.push(summarize(session, unread, pinned, current_task));
|
||||
}
|
||||
// Ensure the pinned windows always exist even before any traffic.
|
||||
if !have_master {
|
||||
@@ -278,7 +341,7 @@ fn handle_sessions_create(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
.map_err(|e| format!("sessions_create: {e}"))?;
|
||||
super::bus::notify_orchestration_message(&agent_id, &session_id, "session");
|
||||
to_json(serde_json::json!({ "session": summarize(session, 0, false) }))
|
||||
to_json(serde_json::json!({ "session": summarize(session, 0, false, None) }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -287,6 +350,9 @@ fn pinned_placeholder(session_id: &str) -> SessionSummary {
|
||||
session_id: session_id.to_string(),
|
||||
agent_id: session_id.to_string(),
|
||||
source: "orchestration".to_string(),
|
||||
harness_type: None,
|
||||
status: derive_status(true).to_string(),
|
||||
current_task: None,
|
||||
label: None,
|
||||
workspace: None,
|
||||
chat_kind: chat_kind_for_session(session_id).as_str().to_string(),
|
||||
@@ -649,4 +715,57 @@ mod tests {
|
||||
params.insert("chat".to_string(), Value::String(" ".to_string()));
|
||||
assert!(required_param(¶ms, "chat").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harness_type_only_for_known_providers() {
|
||||
assert_eq!(harness_type_for("claude").as_deref(), Some("claude"));
|
||||
assert_eq!(harness_type_for("codex").as_deref(), Some("codex"));
|
||||
assert_eq!(harness_type_for("gemini").as_deref(), Some("gemini"));
|
||||
// Sentinel / origin sources are not harnesses.
|
||||
assert_eq!(harness_type_for("master"), None);
|
||||
assert_eq!(harness_type_for("user_created"), None);
|
||||
assert_eq!(harness_type_for("orchestration"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_is_idle_when_active_else_stopped() {
|
||||
assert_eq!(derive_status(true), "idle");
|
||||
assert_eq!(derive_status(false), "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_preview_trims_and_caps_on_char_boundary() {
|
||||
assert_eq!(task_preview(" hello "), "hello");
|
||||
// A multibyte string longer than the cap truncates with an ellipsis and
|
||||
// never exceeds MAX chars (no mid-codepoint panic).
|
||||
let long = "é".repeat(200);
|
||||
let preview = task_preview(&long);
|
||||
assert_eq!(preview.chars().count(), 80);
|
||||
assert!(preview.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_derives_harness_status_and_carries_task() {
|
||||
let session = OrchestrationSession {
|
||||
session_id: "w1".to_string(),
|
||||
agent_id: "@peer".to_string(),
|
||||
source: "claude".to_string(),
|
||||
label: None,
|
||||
workspace: None,
|
||||
last_seq: 3,
|
||||
created_at: "2020-01-01T00:00:00Z".to_string(),
|
||||
// Stale timestamp → not active → stopped.
|
||||
last_message_at: "2020-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
let summary = summarize(session, 2, false, Some("drafting cards".to_string()));
|
||||
assert_eq!(summary.harness_type.as_deref(), Some("claude"));
|
||||
assert_eq!(summary.status, "stopped");
|
||||
assert_eq!(summary.current_task.as_deref(), Some("drafting cards"));
|
||||
assert!(!summary.active);
|
||||
|
||||
// A pinned window is always active → idle, and carries no harness/task.
|
||||
let pinned = pinned_placeholder("master");
|
||||
assert_eq!(pinned.status, "idle");
|
||||
assert!(pinned.harness_type.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,26 @@ pub fn session_last_seq(
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// The most recent message body for a session — the roster task line's "current
|
||||
/// activity". Newest by timestamp then seq; `None` when the session has no
|
||||
/// messages yet. Body is decrypted plaintext (workspace-internal, like the rest
|
||||
/// of this store).
|
||||
pub fn latest_message_preview(
|
||||
conn: &Connection,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT body FROM messages
|
||||
WHERE agent_id = ?1 AND session_id = ?2
|
||||
ORDER BY timestamp DESC, seq DESC LIMIT 1",
|
||||
params![agent_id, session_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// List every persisted session row, newest activity first (stage-7 read surface).
|
||||
pub fn list_sessions(conn: &Connection) -> Result<Vec<OrchestrationSession>> {
|
||||
let mut stmt = conn.prepare(
|
||||
@@ -748,6 +768,31 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_message_preview_returns_newest_or_none() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
with_connection(tmp.path(), |conn| {
|
||||
upsert_session(conn, &session("@a", "h1", 1))?;
|
||||
// No messages yet.
|
||||
assert_eq!(latest_message_preview(conn, "@a", "h1")?, None);
|
||||
|
||||
// Same timestamp → newest is decided by seq DESC.
|
||||
insert_message(conn, &msg("m1", "@a", "h1", 1))?;
|
||||
let mut newer = msg("m2", "@a", "h1", 2);
|
||||
newer.body = "later line".into();
|
||||
insert_message(conn, &newer)?;
|
||||
assert_eq!(
|
||||
latest_message_preview(conn, "@a", "h1")?.as_deref(),
|
||||
Some("later line")
|
||||
);
|
||||
|
||||
// Scoped to (agent, session): a different session is not returned.
|
||||
assert_eq!(latest_message_preview(conn, "@a", "other")?, None);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_diff_is_append_only_with_monotonic_seq_and_idempotent_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user