mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
This commit is contained in:
@@ -41,10 +41,26 @@ vi.mock('../AgentWorldShell', () => ({
|
||||
likePost: vi.fn(),
|
||||
unlikePost: vi.fn(),
|
||||
},
|
||||
streams: { start: vi.fn(), stop: vi.fn(), list: vi.fn().mockResolvedValue({ streams: [] }) },
|
||||
directory: { reverse: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Mock useTinyplaceStream hook ──────────────────────────────────────────────
|
||||
// vi.hoisted so the mock is available inside the vi.mock factory (which is
|
||||
// itself hoisted above the imports). Default: idle (no live push yet).
|
||||
const { mockUseTinyplaceStream } = vi.hoisted(() => ({
|
||||
mockUseTinyplaceStream: vi.fn((_streamId?: string) => ({
|
||||
messages: [] as unknown[],
|
||||
status: 'idle',
|
||||
clearMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useTinyplaceStream', () => ({
|
||||
useTinyplaceStream: (streamId?: string) => mockUseTinyplaceStream(streamId),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
|
||||
|
||||
// ── Sample data (generic placeholders) ───────────────────────────────────────
|
||||
@@ -102,6 +118,11 @@ const samplePostDetail = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Feed real-time stream defaults (#4926): start resolves the canonical
|
||||
// feed:<agentId> id; the hook is idle until a test overrides it.
|
||||
vi.mocked(apiClient.streams.start).mockResolvedValue({ streamId: `feed:${MY_AGENT_ID}` });
|
||||
vi.mocked(apiClient.streams.stop).mockResolvedValue(undefined);
|
||||
mockUseTinyplaceStream.mockReturnValue({ messages: [], status: 'idle', clearMessages: vi.fn() });
|
||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [], count: 0 });
|
||||
vi.mocked(apiClient.graphql.post).mockResolvedValue(samplePostDetail);
|
||||
vi.mocked(apiClient.graphql.user).mockResolvedValue({
|
||||
@@ -740,6 +761,160 @@ describe('delete actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Feed real-time stream (#4926) ────────────────────────────────────────────
|
||||
|
||||
describe('feed real-time stream', () => {
|
||||
test("starts the viewer's own feed stream on mount", async () => {
|
||||
render(<FeedSection />);
|
||||
// Wallet resolves to MY_AGENT_ID, so the panel subscribes to feed:<id>.
|
||||
// Before the fix nothing in FeedSection subscribed to any stream.
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.streams.start)).toHaveBeenCalledWith('feed', MY_AGENT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not start a feed stream when no wallet is configured', async () => {
|
||||
// No solana account → agentId is null → nothing to subscribe to.
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue({ accounts: [] } as any);
|
||||
render(<FeedSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/set up your wallet/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(vi.mocked(apiClient.streams.start)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('refetches the home feed when a feed stream event arrives (no manual refresh)', async () => {
|
||||
const { rerender } = render(<FeedSection />);
|
||||
// Wait for the initial wallet-gated fetch to land.
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled();
|
||||
});
|
||||
const callsBefore = vi.mocked(apiClient.graphql.homeFeed).mock.calls.length;
|
||||
|
||||
// A new event lands on the viewer's feed stream. Keep `status` idle so this
|
||||
// asserts the *event* drives the refetch — not a status transition.
|
||||
mockUseTinyplaceStream.mockReturnValue({
|
||||
messages: [{ stream_id: `feed:${MY_AGENT_ID}`, kind: 'feed', message: {} }],
|
||||
status: 'idle',
|
||||
clearMessages: vi.fn(),
|
||||
});
|
||||
rerender(<FeedSection />);
|
||||
|
||||
// The feed re-fetches on its own. Fails before the fix (feed only fetched on
|
||||
// mount + explicit refetch), passes after.
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows the Live indicator once the feed stream is connected', async () => {
|
||||
mockUseTinyplaceStream.mockReturnValue({
|
||||
messages: [],
|
||||
status: 'connected',
|
||||
clearMessages: vi.fn(),
|
||||
});
|
||||
render(<FeedSection />);
|
||||
expect(await screen.findByTestId('feed-live-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('stops the feed stream it started when the panel unmounts', async () => {
|
||||
const { unmount } = render(<FeedSection />);
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.streams.start)).toHaveBeenCalledWith('feed', MY_AGENT_ID);
|
||||
});
|
||||
|
||||
// Teardown must stop the started stream with its resolved id, not leak it
|
||||
// (the gap CodeRabbit flagged on the sibling DM PR #4988).
|
||||
unmount();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(apiClient.streams.stop)).toHaveBeenCalledWith(`feed:${MY_AGENT_ID}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('a live event merges new posts without collapsing expanded "Load more" pages', async () => {
|
||||
const user = userEvent.setup();
|
||||
const liveItem = {
|
||||
...sampleFeedItem,
|
||||
post: {
|
||||
...samplePost,
|
||||
postId: 'post-live',
|
||||
body: 'LIVENEWPOST',
|
||||
createdAt: new Date(Date.UTC(2026, 1, 1)).toISOString(),
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.graphql.homeFeed)
|
||||
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0)) // mount: page one
|
||||
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, FEED_PAGE_SIZE)) // Load more: page two
|
||||
// The live-event merge refetches page one; it now carries a brand-new post.
|
||||
.mockResolvedValue({
|
||||
items: [liveItem, ...buildFeedPage(FEED_PAGE_SIZE - 1, 0).items],
|
||||
count: 1000,
|
||||
});
|
||||
|
||||
const { rerender } = render(<FeedSection />);
|
||||
await waitFor(() => expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE));
|
||||
|
||||
// Expand to two pages (100 items).
|
||||
await user.click(screen.getByRole('button', { name: /load more/i }));
|
||||
await waitFor(() => expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE * 2));
|
||||
|
||||
// A live event arrives on the viewer's feed stream.
|
||||
mockUseTinyplaceStream.mockReturnValue({
|
||||
messages: [{ stream_id: `feed:${MY_AGENT_ID}`, kind: 'feed', message: {} }],
|
||||
status: 'idle',
|
||||
clearMessages: vi.fn(),
|
||||
});
|
||||
rerender(<FeedSection />);
|
||||
|
||||
// The new post surfaces at the top…
|
||||
expect(await screen.findByText('LIVENEWPOST')).toBeInTheDocument();
|
||||
// …and the two expanded pages are NOT collapsed back to page one — the bug
|
||||
// oxoxDev flagged: resetting to firstPageFeedState would drop these to 49.
|
||||
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE * 2);
|
||||
});
|
||||
|
||||
test('keeps refreshing after the stream buffer caps at 100 events', async () => {
|
||||
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(3, 0));
|
||||
const { rerender } = render(<FeedSection />);
|
||||
await waitFor(() => expect(vi.mocked(apiClient.graphql.homeFeed)).toHaveBeenCalled());
|
||||
|
||||
// `useTinyplaceStream` caps `messages` at 100. Simulate a full buffer whose
|
||||
// newest element (index 99) has a distinct identity per event.
|
||||
const full = (tag: string) =>
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
stream_id: `feed:${MY_AGENT_ID}`,
|
||||
kind: 'feed',
|
||||
message: { seq: i === 99 ? tag : String(i) },
|
||||
}));
|
||||
|
||||
mockUseTinyplaceStream.mockReturnValue({
|
||||
messages: full('a'),
|
||||
status: 'idle',
|
||||
clearMessages: vi.fn(),
|
||||
});
|
||||
rerender(<FeedSection />);
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
const callsAfterFirst = vi.mocked(apiClient.graphql.homeFeed).mock.calls.length;
|
||||
|
||||
// A further event shifts the buffer (drop oldest, append new): length STAYS
|
||||
// 100 but the newest element is a fresh object. A length-keyed effect would
|
||||
// never fire again here; keying on last-message identity still does.
|
||||
mockUseTinyplaceStream.mockReturnValue({
|
||||
messages: full('b'),
|
||||
status: 'idle',
|
||||
clearMessages: vi.fn(),
|
||||
});
|
||||
rerender(<FeedSection />);
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(apiClient.graphql.homeFeed).mock.calls.length).toBeGreaterThan(
|
||||
callsAfterFirst
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pagination (offset-based "Load more", #4923) ─────────────────────────────
|
||||
|
||||
/** Build a home-feed page of `n` items with sequential ids from `start`. */
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import { apiClient } from '../AgentWorldShell';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { useTinyplaceStream } from '../hooks/useTinyplaceStream';
|
||||
import { relativeTime } from './relativeTime';
|
||||
|
||||
const log = debug('agentworld:feed');
|
||||
@@ -645,6 +646,18 @@ export default function FeedSection() {
|
||||
|
||||
const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution();
|
||||
|
||||
// ── Real-time feed updates (#4926) ─────────────────────────────────────────
|
||||
// The SDK exposes a per-feed WebSocket stream (`feeds::stream`); core wires it
|
||||
// as the `feed` StreamKind. We subscribe to the viewer's OWN feed while this
|
||||
// panel is mounted and re-fetch the home feed whenever an event arrives, so
|
||||
// new posts/comments/likes on the viewer's feed surface without a manual
|
||||
// refresh (mirrors the inbox/DM live-update pattern — see #4988). The
|
||||
// aggregated home feed has no server-side WS topic, so followed-author posts
|
||||
// still arrive on the next fetch; this covers the viewer's own feed activity.
|
||||
const feedStreamId = myAgentId ? `feed:${myAgentId}` : undefined;
|
||||
const { messages: streamMessages, status: streamStatus } = useTinyplaceStream(feedStreamId);
|
||||
const feedStreamRef = useRef<string | null>(null);
|
||||
|
||||
// Guards async setState after unmount. The initial fetch effect uses its own
|
||||
// `cancelled` flag; "Load more" fetches outlive no single effect, so they read
|
||||
// this ref instead.
|
||||
@@ -869,14 +882,96 @@ export default function FeedSection() {
|
||||
|
||||
// ── Refetch feed ───────────────────────────────────────────────────────────
|
||||
|
||||
// A fresh compose invalidates offsets, so reset pagination to the first page.
|
||||
const refetchFeed = () => {
|
||||
// A fresh compose/delete invalidates offsets, so reset pagination to the first
|
||||
// page. `useCallback` keeps a stable identity for the callers below.
|
||||
const refetchFeed = useCallback(() => {
|
||||
void apiClient.graphql
|
||||
.homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true })
|
||||
.then(result => {
|
||||
setFeedState(firstPageFeedState(result));
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reconcile a live feed event WITHOUT collapsing pagination. A stream event
|
||||
// means "something changed on your feed", so fetch page one and dedupe-merge
|
||||
// the fresh items into the existing list — which may already span several
|
||||
// "Load more" pages — while preserving the pagination cursor (nextOffset /
|
||||
// hasMore). This is deliberately NOT `refetchFeed`: resetting to page one on
|
||||
// every event would discard every older page the viewer expanded (oxoxDev
|
||||
// review, #4994). New items sort to the top; already-loaded pages stay put.
|
||||
const mergeLiveFeedUpdate = useCallback(() => {
|
||||
void apiClient.graphql
|
||||
.homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true })
|
||||
.then(result => {
|
||||
if (!mountedRef.current) return;
|
||||
const page = Array.isArray(result?.items) ? result.items : [];
|
||||
setFeedState(prev => {
|
||||
// Still loading / errored → no expanded pages to preserve; render one.
|
||||
if (prev.status !== 'ok') return firstPageFeedState(result);
|
||||
const seen = new Set(prev.items.map(item => item.post.postId));
|
||||
const fresh = page.filter(item => !seen.has(item.post.postId));
|
||||
if (fresh.length === 0) return prev; // nothing new to surface
|
||||
const merged = sortedHomeFeedItems({ items: [...prev.items, ...fresh] });
|
||||
log('live feed event merged', { fresh: fresh.length, total: merged.length });
|
||||
return { ...prev, items: merged };
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mountedRef.current) return;
|
||||
log('live feed merge failed: %s', String(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Start / stop the viewer's own feed stream ──────────────────────────────
|
||||
// Open the stream while a resolved wallet is present; stop it on unmount /
|
||||
// identity change. Failures are non-fatal — the feed still works via the
|
||||
// mount fetch + explicit refetches, just without live push. Mirrors the
|
||||
// InboxPanel/DM stream lifecycle (start-after-cancel guard included) so a
|
||||
// rapid identity change can't orphan a live backend subscription (#4926).
|
||||
useEffect(() => {
|
||||
if (!myAgentId) return;
|
||||
let cancelled = false;
|
||||
void apiClient.streams
|
||||
.start('feed', myAgentId)
|
||||
.then(res => {
|
||||
if (cancelled) {
|
||||
void apiClient.streams.stop(res.streamId).catch(err => {
|
||||
log('feed stream stop-after-cancel failed (%s): %s', res.streamId, String(err));
|
||||
});
|
||||
return;
|
||||
}
|
||||
feedStreamRef.current = res.streamId;
|
||||
log('feed stream started: %s', res.streamId);
|
||||
})
|
||||
.catch(err => {
|
||||
log('feed stream start failed: %s', String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (feedStreamRef.current !== null) {
|
||||
const stopId = feedStreamRef.current;
|
||||
void apiClient.streams.stop(stopId).catch(err => {
|
||||
log('feed stream stop failed (%s): %s', stopId, String(err));
|
||||
});
|
||||
feedStreamRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [myAgentId]);
|
||||
|
||||
// Reconcile the open feed whenever a new stream event arrives. Key the effect
|
||||
// on the NEWEST message's identity, not `streamMessages.length`: the stream
|
||||
// buffer is capped at 100 (`useTinyplaceStream`), so once full its length
|
||||
// plateaus and a length-keyed effect would stop firing while events keep
|
||||
// arriving (Codex P2 / oxoxDev). The buffer appends a fresh object per event
|
||||
// (`[...prev.slice(-99), msg]`), so the last element's identity advances
|
||||
// monotonically even after the cap. Merge (not reset) so expanded pages stay.
|
||||
const lastStreamMessage =
|
||||
streamMessages.length > 0 ? streamMessages[streamMessages.length - 1] : null;
|
||||
useEffect(() => {
|
||||
if (!myAgentId || !lastStreamMessage) return;
|
||||
log('feed stream event -> merging live feed update');
|
||||
mergeLiveFeedUpdate();
|
||||
}, [lastStreamMessage, myAgentId, mergeLiveFeedUpdate]);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -971,6 +1066,16 @@ export default function FeedSection() {
|
||||
|
||||
return (
|
||||
<PanelScaffold description="Social feed">
|
||||
{streamStatus === 'connected' && (
|
||||
<div className="mb-2 flex justify-end">
|
||||
<span
|
||||
data-testid="feed-live-indicator"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-green-600 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
{t('agentworld.feed.live', 'Live')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{myAgentId && feedState.status === 'ok' && (
|
||||
<FeedComposer myAgentId={myAgentId} onPostCreated={refetchFeed} />
|
||||
)}
|
||||
|
||||
@@ -7046,6 +7046,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'مثال: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'وقت التسليم المتوقع',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'مثال: أسبوعان',
|
||||
'agentworld.feed.live': 'مباشر',
|
||||
'agentworld.jobs.applyModal.cancel': 'إلغاء',
|
||||
'agentworld.jobs.applyModal.submit': 'إرسال الطلب',
|
||||
'agentworld.jobs.applyModal.submitting': 'جارٍ التقديم…',
|
||||
|
||||
@@ -7212,6 +7212,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'যেমন: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'আনুমানিক ডেলিভারি',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'যেমন: 2 সপ্তাহ',
|
||||
'agentworld.feed.live': 'লাইভ',
|
||||
'agentworld.jobs.applyModal.cancel': 'বাতিল',
|
||||
'agentworld.jobs.applyModal.submit': 'আবেদন জমা দিন',
|
||||
'agentworld.jobs.applyModal.submitting': 'আবেদন করা হচ্ছে…',
|
||||
|
||||
@@ -7426,6 +7426,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'z. B. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Voraussichtliche Lieferzeit',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'z. B. 2 Wochen',
|
||||
'agentworld.feed.live': 'Live',
|
||||
'agentworld.jobs.applyModal.cancel': 'Abbrechen',
|
||||
'agentworld.jobs.applyModal.submit': 'Bewerbung einreichen',
|
||||
'agentworld.jobs.applyModal.submitting': 'Wird eingereicht…',
|
||||
|
||||
@@ -7585,6 +7585,7 @@ const en: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'e.g. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Estimated Delivery',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'e.g. 2 weeks',
|
||||
'agentworld.feed.live': 'Live',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancel',
|
||||
'agentworld.jobs.applyModal.submit': 'Submit Application',
|
||||
'agentworld.jobs.applyModal.submitting': 'Applying…',
|
||||
|
||||
@@ -7363,6 +7363,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ej. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Entrega estimada',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ej. 2 semanas',
|
||||
'agentworld.feed.live': 'En vivo',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancelar',
|
||||
'agentworld.jobs.applyModal.submit': 'Enviar solicitud',
|
||||
'agentworld.jobs.applyModal.submitting': 'Enviando…',
|
||||
|
||||
@@ -7397,6 +7397,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Délai estimé',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semaines',
|
||||
'agentworld.feed.live': 'En direct',
|
||||
'agentworld.jobs.applyModal.cancel': 'Annuler',
|
||||
'agentworld.jobs.applyModal.submit': 'Soumettre la candidature',
|
||||
'agentworld.jobs.applyModal.submitting': 'Envoi en cours…',
|
||||
|
||||
@@ -7207,6 +7207,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'उदा. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'अनुमानित डिलीवरी',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'उदा. 2 सप्ताह',
|
||||
'agentworld.feed.live': 'लाइव',
|
||||
'agentworld.jobs.applyModal.cancel': 'रद्द करें',
|
||||
'agentworld.jobs.applyModal.submit': 'आवेदन सबमिट करें',
|
||||
'agentworld.jobs.applyModal.submitting': 'आवेदन हो रहा है…',
|
||||
|
||||
@@ -7244,6 +7244,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'mis. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Estimasi Pengiriman',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'mis. 2 minggu',
|
||||
'agentworld.feed.live': 'Langsung',
|
||||
'agentworld.jobs.applyModal.cancel': 'Batal',
|
||||
'agentworld.jobs.applyModal.submit': 'Kirim Lamaran',
|
||||
'agentworld.jobs.applyModal.submitting': 'Melamar…',
|
||||
|
||||
@@ -7349,6 +7349,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'es. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Consegna stimata',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'es. 2 settimane',
|
||||
'agentworld.feed.live': 'In diretta',
|
||||
'agentworld.jobs.applyModal.cancel': 'Annulla',
|
||||
'agentworld.jobs.applyModal.submit': 'Invia candidatura',
|
||||
'agentworld.jobs.applyModal.submitting': 'Candidatura in corso…',
|
||||
|
||||
@@ -7124,6 +7124,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': '예: 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': '예상 납기',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': '예: 2주',
|
||||
'agentworld.feed.live': '실시간',
|
||||
'agentworld.jobs.applyModal.cancel': '취소',
|
||||
'agentworld.jobs.applyModal.submit': '지원서 제출',
|
||||
'agentworld.jobs.applyModal.submitting': '지원 중…',
|
||||
|
||||
@@ -7321,6 +7321,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'np. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Szacowany czas realizacji',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'np. 2 tygodnie',
|
||||
'agentworld.feed.live': 'Na żywo',
|
||||
'agentworld.jobs.applyModal.cancel': 'Anuluj',
|
||||
'agentworld.jobs.applyModal.submit': 'Wyślij aplikację',
|
||||
'agentworld.jobs.applyModal.submitting': 'Wysyłanie…',
|
||||
|
||||
@@ -7330,6 +7330,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Prazo estimado de entrega',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semanas',
|
||||
'agentworld.feed.live': 'Ao vivo',
|
||||
'agentworld.jobs.applyModal.cancel': 'Cancelar',
|
||||
'agentworld.jobs.applyModal.submit': 'Enviar candidatura',
|
||||
'agentworld.jobs.applyModal.submitting': 'A enviar…',
|
||||
|
||||
@@ -7294,6 +7294,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': 'напр. 450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': 'Ориентировочный срок выполнения',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': 'напр. 2 недели',
|
||||
'agentworld.feed.live': 'В эфире',
|
||||
'agentworld.jobs.applyModal.cancel': 'Отмена',
|
||||
'agentworld.jobs.applyModal.submit': 'Отправить заявку',
|
||||
'agentworld.jobs.applyModal.submitting': 'Отправка…',
|
||||
|
||||
@@ -6817,6 +6817,7 @@ const messages: TranslationMap = {
|
||||
'agentworld.jobs.applyModal.bidAmountPlaceholder': '例:450 USDC',
|
||||
'agentworld.jobs.applyModal.deliveryLabel': '预计交付时间',
|
||||
'agentworld.jobs.applyModal.deliveryPlaceholder': '例:2周',
|
||||
'agentworld.feed.live': '实时',
|
||||
'agentworld.jobs.applyModal.cancel': '取消',
|
||||
'agentworld.jobs.applyModal.submit': '提交申请',
|
||||
'agentworld.jobs.applyModal.submitting': '申请中…',
|
||||
|
||||
@@ -2604,10 +2604,12 @@ pub(crate) fn handle_tinyplace_streams_start(params: Map<String, Value>) -> Cont
|
||||
let kind = match kind_str_trimmed {
|
||||
"inbox" => super::streams::StreamKind::Inbox,
|
||||
"conversation" => super::streams::StreamKind::Conversation,
|
||||
"feed" => super::streams::StreamKind::Feed,
|
||||
_ => return Err(format!("unsupported streamType: {kind_str_trimmed}")),
|
||||
};
|
||||
|
||||
// conversation streams require a target id.
|
||||
// conversation and feed streams require a target id (conversation_id /
|
||||
// feed handle respectively); inbox is a singleton with no target.
|
||||
let target_id = params
|
||||
.get("streamId")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -2617,6 +2619,9 @@ pub(crate) fn handle_tinyplace_streams_start(params: Map<String, Value>) -> Cont
|
||||
if kind == super::streams::StreamKind::Conversation && target_id.is_none() {
|
||||
return Err("streamId is required for conversation streams".to_string());
|
||||
}
|
||||
if kind == super::streams::StreamKind::Feed && target_id.is_none() {
|
||||
return Err("streamId is required for feed streams".to_string());
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} streams_start kind={kind_str_trimmed} target_id={:?}",
|
||||
@@ -5817,6 +5822,19 @@ mod tests {
|
||||
assert!(err.contains("streamId"), "got: {err}");
|
||||
}
|
||||
|
||||
/// streams_start rejects a feed stream without a streamId (the feed handle).
|
||||
/// Regression for #4926: "feed" is a valid streamType but, like
|
||||
/// "conversation", it is target-scoped and must carry a streamId.
|
||||
#[test]
|
||||
fn streams_start_feed_requires_stream_id() {
|
||||
let mut params = Map::new();
|
||||
params.insert("streamType".to_string(), Value::String("feed".to_string()));
|
||||
let err = block_on(handle_tinyplace_streams_start(params)).unwrap_err();
|
||||
assert!(err.contains("streamId"), "got: {err}");
|
||||
// And it must NOT be rejected as an unsupported streamType.
|
||||
assert!(!err.contains("unsupported"), "got: {err}");
|
||||
}
|
||||
|
||||
/// streams_stop rejects a missing/blank streamId.
|
||||
#[test]
|
||||
fn streams_stop_requires_stream_id() {
|
||||
|
||||
@@ -36,6 +36,8 @@ pub(crate) const MAX_CONCURRENT_STREAMS: usize = 5;
|
||||
pub(crate) enum StreamKind {
|
||||
Inbox,
|
||||
Conversation,
|
||||
/// A single feed's live post/comment/like stream, keyed by feed handle.
|
||||
Feed,
|
||||
}
|
||||
|
||||
/// Metadata for an active stream (returned to the renderer).
|
||||
@@ -84,6 +86,7 @@ pub(crate) fn stream_id_for(kind: StreamKind, target_id: Option<&str>) -> String
|
||||
match kind {
|
||||
StreamKind::Inbox => "inbox".to_string(),
|
||||
StreamKind::Conversation => format!("conversation:{}", target_id.unwrap_or("")),
|
||||
StreamKind::Feed => format!("feed:{}", target_id.unwrap_or("")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +141,10 @@ impl StreamManager {
|
||||
let conv_id = target_id.as_deref().unwrap_or("");
|
||||
client.conversations.stream(conv_id, None, None)
|
||||
}
|
||||
StreamKind::Feed => {
|
||||
let handle = target_id.as_deref().unwrap_or("");
|
||||
client.feeds.stream(handle, None)
|
||||
}
|
||||
};
|
||||
|
||||
// Publish connecting status.
|
||||
@@ -238,6 +245,7 @@ fn kind_to_str(kind: &StreamKind) -> &'static str {
|
||||
match kind {
|
||||
StreamKind::Inbox => "inbox",
|
||||
StreamKind::Conversation => "conversation",
|
||||
StreamKind::Feed => "feed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +332,9 @@ mod tests {
|
||||
stream_id_for(StreamKind::Conversation, Some("abc123")),
|
||||
"conversation:abc123"
|
||||
);
|
||||
// Feed streams are keyed by feed handle (#4926).
|
||||
assert_eq!(stream_id_for(StreamKind::Feed, Some("alice")), "feed:alice");
|
||||
assert_eq!(stream_id_for(StreamKind::Feed, None), "feed:");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user