diff --git a/app/src/agentworld/pages/FeedSection.test.tsx b/app/src/agentworld/pages/FeedSection.test.tsx index 9e8d93a52..2a36d45a6 100644 --- a/app/src/agentworld/pages/FeedSection.test.tsx +++ b/app/src/agentworld/pages/FeedSection.test.tsx @@ -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: 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(); + // Wallet resolves to MY_AGENT_ID, so the panel subscribes to feed:. + // 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(); + 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(); + // 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(); + + // 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(); + expect(await screen.findByTestId('feed-live-indicator')).toBeInTheDocument(); + }); + + test('stops the feed stream it started when the panel unmounts', async () => { + const { unmount } = render(); + 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(); + 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(); + + // 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(); + 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(); + 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(); + 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`. */ diff --git a/app/src/agentworld/pages/FeedSection.tsx b/app/src/agentworld/pages/FeedSection.tsx index 8afa9ac52..b0a2ffc22 100644 --- a/app/src/agentworld/pages/FeedSection.tsx +++ b/app/src/agentworld/pages/FeedSection.tsx @@ -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(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 ( + {streamStatus === 'connected' && ( +
+ + + {t('agentworld.feed.live', 'Live')} + +
+ )} {myAgentId && feedState.status === 'ok' && ( )} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 57832ea70..049de7b6d 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'جارٍ التقديم…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 491867a9c..f977b5e9a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'আবেদন করা হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index dee022958..5f8be7d87 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7540db7f1..440e56097 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index aa8eaef77..89c945005 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 14d7f4a90..410b71fac 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7fb5a9dd6..240f26d85 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'आवेदन हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 126d0e8c0..37a0fbec3 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 676751c94..612161102 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 503625b04..610c15c02 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '지원 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c89ad8d05..2f2aa0e55 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 845114238..35fc908aa 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 2e8256102..ed2a22f2c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Отправка…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 678738cee..bee42f267 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '申请中…', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 5a6a488d2..fabc43a6b 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2604,10 +2604,12 @@ pub(crate) fn handle_tinyplace_streams_start(params: Map) -> 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) -> 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() { diff --git a/src/openhuman/tinyplace/streams.rs b/src/openhuman/tinyplace/streams.rs index 9fc7a2ae9..0a218dea0 100644 --- a/src/openhuman/tinyplace/streams.rs +++ b/src/openhuman/tinyplace/streams.rs @@ -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]