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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const spectatorQueries = vi.hoisted(() => ({
data: null as unknown,
refetch: vi.fn(),
},
sessionMessagesQuery: null as {
enabled?: boolean;
refetchInterval?: unknown;
} | null,
}));
const statusHelpers = vi.hoisted(() => ({
cancellable: false,
Expand Down Expand Up @@ -170,11 +174,13 @@ vi.mock('@/lib/trpc', () => ({
}),
}));
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: { queryKey?: unknown[] }) => {
useQuery: (options: { queryKey?: unknown[]; enabled?: boolean; refetchInterval?: unknown }) => {
const key = options.queryKey?.[0];
return key === 'codeReviews.getReviewStreamInfo'
? spectatorQueries.streamInfo
: spectatorQueries.sessionMessages;
if (key === 'codeReviews.getReviewStreamInfo') {
return spectatorQueries.streamInfo;
}
spectatorQueries.sessionMessagesQuery = options;
return spectatorQueries.sessionMessages;
},
}));
vi.mock('@/components/code-reviewer/review-spectator-stream', () => ({
Expand Down Expand Up @@ -307,6 +313,7 @@ beforeEach(() => {
spectatorQueries.sessionMessages.isError = false;
spectatorQueries.sessionMessages.data = { success: true, entries: [] };
spectatorQueries.sessionMessages.refetch.mockClear();
spectatorQueries.sessionMessagesQuery = null;
spectatorStream.createReviewSpectatorStream.mockReset();
spectatorStream.createReviewSpectatorStream.mockResolvedValue({
connect: vi.fn(),
Expand Down Expand Up @@ -666,6 +673,57 @@ describe('ReviewDetailScreen spectator transcript', () => {
expect(texts).toContain('Waiting for the review transcript.');
});

it('polls session messages for an in-progress org review and does not open a websocket', () => {
spectatorQueries.streamInfo.data = makeStreamInfo({
status: 'running',
cloudAgentSessionId: 'agent-1',
organizationId: 'org-1',
});
spectatorQueries.sessionMessages.data = {
success: true,
entries: [{ timestamp: 't1', message: 'Tool: read', eventType: 'tool' }],
};
detail.data = {
success: true,
review: makeReview({ status: 'running' }),
tokenUsage: { input: 0, output: 0 },
};

renderScreen(true);

expect(spectatorStream.createReviewSpectatorStream).not.toHaveBeenCalled();
expect(spectatorQueries.sessionMessagesQuery?.enabled).toBe(true);
expect(spectatorQueries.sessionMessagesQuery?.refetchInterval).toBe(2000);
const items = sessionListRenders.list.at(-1)?.items as { message?: string }[] | undefined;
expect(items?.map(item => item.message)).toEqual(['Tool: read']);
});

it('keeps org poll rows when a later snapshot is empty', () => {
spectatorQueries.streamInfo.data = makeStreamInfo({
status: 'running',
cloudAgentSessionId: 'agent-1',
organizationId: 'org-1',
});
spectatorQueries.sessionMessages.data = {
success: true,
entries: [{ timestamp: 't1', message: 'Tool: read', eventType: 'tool' }],
};
detail.data = {
success: true,
review: makeReview({ status: 'running' }),
tokenUsage: { input: 0, output: 0 },
};

const renderer = mountScreen(true);
spectatorQueries.sessionMessages.data = { success: true, entries: [] };
act(() => {
renderer.update(createElement(ReviewDetailScreen, { scope: 'personal', reviewId: 'rev-1' }));
});

const items = sessionListRenders.list.at(-1)?.items as { message?: string }[] | undefined;
expect(items?.map(item => item.message)).toEqual(['Tool: read']);
});

it('shows empty copy for a completed review without a session', () => {
spectatorQueries.streamInfo.data = makeStreamInfo({ status: 'completed' });
detail.data = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';

import {
getCodeReviewDisplayBehavior,
resolveReviewSpectatorMode,
retainPolledSpectatorRows,
reviewSpectatorStreamInfoInterval,
} from './review-spectator-behavior';

describe('getCodeReviewDisplayBehavior', () => {
it('loads persisted history without polling for a nonterminal V1 review', () => {
expect(
getCodeReviewDisplayBehavior({
agentVersion: 'v1',
status: 'running',
})
).toEqual({
isHistorical: true,
isTerminal: false,
shouldLoadMessages: true,
shouldPollMessages: false,
shouldPollStatus: false,
});
});

it('keeps a personal V2 review on the live stream path while polling its status', () => {
expect(
getCodeReviewDisplayBehavior({
agentVersion: 'v2',
status: 'running',
})
).toEqual({
isHistorical: false,
isTerminal: false,
shouldLoadMessages: false,
shouldPollMessages: false,
shouldPollStatus: true,
});
});

it.each(['pending', 'queued', 'running'])(
'polls organization review transcripts when %s',
status => {
expect(
getCodeReviewDisplayBehavior({
agentVersion: 'v2',
status,
organizationId: 'org-1',
})
).toEqual({
isHistorical: false,
isTerminal: false,
shouldLoadMessages: true,
shouldPollMessages: true,
shouldPollStatus: true,
});
}
);

it.each(['completed', 'failed', 'cancelled', 'interrupted'])(
'loads the transcript without polling when %s',
status => {
expect(
getCodeReviewDisplayBehavior({
agentVersion: 'v2',
status,
organizationId: 'org-1',
})
).toEqual({
isHistorical: false,
isTerminal: true,
shouldLoadMessages: true,
shouldPollMessages: false,
shouldPollStatus: false,
});
}
);
});

describe('retainPolledSpectatorRows', () => {
it('keeps the last non-empty poll when the latest snapshot is empty', () => {
expect(retainPolledSpectatorRows([], ['kept'], true)).toEqual(['kept']);
});

it('uses the latest snapshot when it has rows', () => {
expect(retainPolledSpectatorRows(['next'], ['kept'], true)).toEqual(['next']);
});

it('does not retain empty history after polling stops', () => {
expect(retainPolledSpectatorRows([], ['kept'], false)).toEqual([]);
});
});

describe('reviewSpectatorStreamInfoInterval', () => {
it('polls while stream info has not loaded', () => {
expect(reviewSpectatorStreamInfoInterval(undefined)).toBe(2000);
});

it('polls an in-flight v2 review even after the session id appears', () => {
expect(
reviewSpectatorStreamInfoInterval({
success: true,
agentVersion: 'v2',
status: 'running',
cloudAgentSessionId: 'agent-1',
})
).toBe(2000);
});

it('stops polling a terminal review', () => {
expect(
reviewSpectatorStreamInfoInterval({
success: true,
agentVersion: 'v2',
status: 'completed',
cloudAgentSessionId: 'agent-1',
})
).toBe(false);
});
});

describe('resolveReviewSpectatorMode', () => {
it('polls messages for an in-progress org review and does not open a live stream', () => {
expect(
resolveReviewSpectatorMode(
{
agentVersion: 'v2',
status: 'running',
organizationId: 'org-1',
cloudAgentSessionId: 'agent-1',
},
'running',
0
)
).toEqual({
isTerminal: false,
shouldPollMessages: true,
shouldLoadHistory: true,
liveCloudId: null,
});
});

it('opens a live stream for an in-progress personal review', () => {
expect(
resolveReviewSpectatorMode(
{
agentVersion: 'v2',
status: 'running',
cloudAgentSessionId: 'agent-1',
},
'running',
0
)
).toEqual({
isTerminal: false,
shouldPollMessages: false,
shouldLoadHistory: false,
liveCloudId: 'agent-1',
});
});

it('loads history when the parent status is already terminal', () => {
expect(
resolveReviewSpectatorMode(
{
agentVersion: 'v2',
status: 'running',
organizationId: 'org-1',
cloudAgentSessionId: 'agent-1',
},
'completed',
0
)
).toMatchObject({
isTerminal: true,
shouldPollMessages: false,
shouldLoadHistory: true,
liveCloudId: null,
});
});
});
108 changes: 108 additions & 0 deletions apps/mobile/src/components/code-reviewer/review-spectator-behavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const TERMINAL_REVIEW_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted']);

type CodeReviewStreamSnapshot = {
agentVersion: string;
status: string;
organizationId?: string;
};

type CodeReviewDisplayBehavior = {
isHistorical: boolean;
isTerminal: boolean;
shouldLoadMessages: boolean;
shouldPollMessages: boolean;
shouldPollStatus: boolean;
};

/**
* Same gates as apps/web `getCodeReviewDisplayBehavior`. Org reviews run as
* bot-owned sessions, so a stream ticket is creator-only (web #5781). In-flight
* org transcripts must poll `getSessionMessages` instead of opening a socket.
*/
export function getCodeReviewDisplayBehavior(
snapshot: CodeReviewStreamSnapshot
): CodeReviewDisplayBehavior {
const isHistorical = snapshot.agentVersion !== 'v2';
const isTerminal = TERMINAL_REVIEW_STATUSES.has(snapshot.status);
const shouldPollStatus = !isHistorical && !isTerminal;
const shouldPollMessages = shouldPollStatus && Boolean(snapshot.organizationId);

return {
isHistorical,
isTerminal,
shouldLoadMessages: isHistorical || isTerminal || shouldPollMessages,
shouldPollMessages,
shouldPollStatus,
};
}

/** Keep the last non-empty poll so an empty ingest snapshot cannot blank the log. */
export function retainPolledSpectatorRows<T>(
latest: readonly T[],
retained: readonly T[],
shouldPoll: boolean
): readonly T[] {
if (shouldPoll && latest.length === 0 && retained.length > 0) {
return retained;
}
return latest;
}

type StreamInfo = {
agentVersion: string;
status: string;
organizationId?: string;
cloudAgentSessionId: string | null;
};

export function reviewSpectatorStreamInfoInterval(
data: ({ success?: boolean } & Partial<StreamInfo>) | undefined
): number | false {
if (!data?.success || data.agentVersion === undefined || data.status === undefined) {
return 2000;
}
return getCodeReviewDisplayBehavior({
agentVersion: data.agentVersion,
status: data.status,
organizationId: data.organizationId,
}).shouldPollStatus
? 2000
: false;
}

type ReviewSpectatorMode = {
isTerminal: boolean;
shouldPollMessages: boolean;
shouldLoadHistory: boolean;
liveCloudId: string | null;
};

export function resolveReviewSpectatorMode(
info: StreamInfo | null,
parentStatus: string,
liveRowCount: number
): ReviewSpectatorMode {
const parentIsTerminal = TERMINAL_REVIEW_STATUSES.has(parentStatus);
if (info === null) {
return {
isTerminal: parentIsTerminal,
shouldPollMessages: false,
shouldLoadHistory: false,
liveCloudId: null,
};
}
const displayBehavior = getCodeReviewDisplayBehavior({
agentVersion: info.agentVersion,
status: parentIsTerminal ? parentStatus : info.status,
organizationId: info.organizationId,
});
return {
isTerminal: displayBehavior.isTerminal,
shouldPollMessages: displayBehavior.shouldPollMessages,
shouldLoadHistory: displayBehavior.shouldLoadMessages && liveRowCount === 0,
liveCloudId:
displayBehavior.shouldLoadMessages || info.cloudAgentSessionId === null
? null
: info.cloudAgentSessionId,
};
}
Loading