diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 65db79b987..3b222d4d5d 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -49,6 +49,7 @@ import { getCachedProjectThreadListInvalidationQueryKeys, getCachedRootOrderThreadListInvalidationQueryKeys, getCachedSidebarNavigationThreads, + getCachedThreadListPlaceholder, getEnvironmentBranchListInvalidationQueryKeys, getEnvironmentRecordInvalidationQueryKeys, getEnvironmentWorkspaceStateInvalidationQueryKeys, @@ -264,6 +265,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadListQueriesForBackgroundActivity, // Sidebar rows render active workflow/background task state. dirtyThreadSearchQueries, // Indexed conversation content may now match a search query. dirtyThreadTimelineQueries, // Timeline rows are built from appended events. + dirtyThreadPullRequestQueryForCompletedTurn, // A turn may create a remote PR without changing the workspace. dirtyThreadPromptHistoryQueriesForTurnRequests, // Follow-up recall is built from client turn requests. ], }, @@ -687,6 +689,24 @@ function dirtyThreadPromptHistoryQueriesForTurnRequests({ return getThreadPromptHistoryInvalidationQueryKeys({ threadId }); } +function dirtyThreadPullRequestQueryForCompletedTurn({ + eventTypes, + queryClient, + threadId, +}: ThreadRealtimeDirtyContext): QueryKey[] { + if (!threadId || !eventTypes?.includes("turn/completed")) { + return []; + } + const cachedThread = + queryClient.getQueryData(threadQueryKey(threadId)) ?? + getCachedThreadListPlaceholder(queryClient, threadId) ?? + getCachedSidebarNavigationThreads(queryClient).find( + (thread) => thread.id === threadId, + ); + const environmentId = cachedThread?.environmentId; + return environmentId ? [environmentPullRequestQueryKey(environmentId)] : []; +} + function dirtyThreadPendingInteractionQueries({ threadId, }: ThreadRealtimeDirtyContext): QueryKey[] { diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 7234f96791..be0863e94d 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -12,6 +12,7 @@ import { archivedThreadsListQueryKey, environmentDiffFilesQueryKey, environmentDiffPatchQueryKey, + environmentPullRequestQueryKey, environmentWorkStatusQueryKey, hostPathExistenceQueryKey, projectPathsQueryKey, @@ -412,6 +413,57 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it.each(["thread detail", "sidebar navigation"] as const)( + "refetches the active environment pull request from %s when a turn completes", + async (cacheSource) => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const pullRequestKey = environmentPullRequestQueryKey("env-1"); + const nextPullRequest = { + outcome: "available", + pullRequest: { number: 42 }, + }; + const pullRequestQueryFn = vi.fn(async () => nextPullRequest); + if (cacheSource === "thread detail") { + queryClient.setQueryData(threadQueryKey("thr_1"), { + environmentId: "env-1", + id: "thr_1", + }); + } else { + queryClient.setQueryData(sidebarNavigationQueryKey(), { + personalProject: { threads: [] }, + projects: [ + { + threads: [{ environmentId: "env-1", id: "thr_1" }], + }, + ], + }); + } + queryClient.setQueryData(pullRequestKey, { outcome: "absent" }); + const pullRequestObserver = new QueryObserver(queryClient, { + queryFn: pullRequestQueryFn, + queryKey: pullRequestKey, + staleTime: Infinity, + }); + const unsubscribePullRequest = pullRequestObserver.subscribe(() => {}); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { eventTypes: ["turn/completed"] }, + changes: ["events-appended"], + }); + await vi.advanceTimersByTimeAsync(50); + + expect(pullRequestQueryFn).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(pullRequestKey)).toEqual(nextPullRequest); + + unsubscribePullRequest(); + effects.dispose(); + }, + ); + it("invalidates cached thread search results when environment metadata changes", () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext();