fix(tinyplace): real-time feed updates via feeds stream (#4926) (#4994)

This commit is contained in:
Mega Mind
2026-07-18 06:42:48 +03:00
committed by GitHub
parent 570388fe1b
commit c9f20afcc7
18 changed files with 327 additions and 4 deletions
@@ -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`. */
+108 -3
View File
@@ -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} />
)}
+1
View File
@@ -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': 'جارٍ التقديم…',
+1
View File
@@ -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': 'আবেদন করা হচ্ছে…',
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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': 'आवेदन हो रहा है…',
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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': '지원 중…',
+1
View File
@@ -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…',
+1
View File
@@ -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…',
+1
View File
@@ -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': 'Отправка…',
+1
View File
@@ -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': '申请中…',
+19 -1
View File
@@ -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() {
+11
View File
@@ -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]