|
1 | 1 | import { QueryClient } from "@tanstack/react-query"; |
2 | | -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | +import { |
| 3 | + EnvironmentId, |
| 4 | + ProjectId, |
| 5 | + ThreadId, |
| 6 | + TurnId, |
| 7 | + type OrchestrationShellSnapshot, |
| 8 | +} from "@t3tools/contracts"; |
3 | 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
4 | 10 |
|
5 | 11 | const mockSubscribeThread = vi.fn(); |
@@ -65,6 +71,74 @@ vi.mock("../../rpc/wsTransport", () => ({ |
65 | 71 | WsTransport: MockWsTransport, |
66 | 72 | })); |
67 | 73 |
|
| 74 | +function makeThreadShellSnapshot(params: { |
| 75 | + readonly threadId: ThreadId; |
| 76 | + readonly sessionStatus?: |
| 77 | + | "idle" |
| 78 | + | "starting" |
| 79 | + | "running" |
| 80 | + | "ready" |
| 81 | + | "interrupted" |
| 82 | + | "stopped" |
| 83 | + | "error"; |
| 84 | + readonly hasPendingApprovals?: boolean; |
| 85 | + readonly hasPendingUserInput?: boolean; |
| 86 | + readonly hasActionableProposedPlan?: boolean; |
| 87 | +}): OrchestrationShellSnapshot { |
| 88 | + const projectId = ProjectId.make("project-1"); |
| 89 | + const turnId = TurnId.make("turn-1"); |
| 90 | + |
| 91 | + return { |
| 92 | + snapshotSequence: 1, |
| 93 | + projects: [], |
| 94 | + updatedAt: "2026-04-13T00:00:00.000Z", |
| 95 | + threads: [ |
| 96 | + { |
| 97 | + id: params.threadId, |
| 98 | + projectId, |
| 99 | + title: "Thread", |
| 100 | + modelSelection: { |
| 101 | + provider: "codex", |
| 102 | + model: "gpt-5-codex", |
| 103 | + }, |
| 104 | + runtimeMode: "full-access", |
| 105 | + interactionMode: "default", |
| 106 | + branch: null, |
| 107 | + worktreePath: null, |
| 108 | + latestTurn: |
| 109 | + params.sessionStatus === "running" |
| 110 | + ? { |
| 111 | + turnId, |
| 112 | + state: "running", |
| 113 | + requestedAt: "2026-04-13T00:00:00.000Z", |
| 114 | + startedAt: "2026-04-13T00:00:01.000Z", |
| 115 | + completedAt: null, |
| 116 | + assistantMessageId: null, |
| 117 | + } |
| 118 | + : null, |
| 119 | + createdAt: "2026-04-13T00:00:00.000Z", |
| 120 | + updatedAt: "2026-04-13T00:00:00.000Z", |
| 121 | + archivedAt: null, |
| 122 | + session: params.sessionStatus |
| 123 | + ? { |
| 124 | + threadId: params.threadId, |
| 125 | + status: params.sessionStatus, |
| 126 | + providerName: "codex", |
| 127 | + runtimeMode: "full-access", |
| 128 | + activeTurnId: params.sessionStatus === "running" ? turnId : null, |
| 129 | + lastError: null, |
| 130 | + updatedAt: "2026-04-13T00:00:00.000Z", |
| 131 | + } |
| 132 | + : null, |
| 133 | + latestUserMessageAt: null, |
| 134 | + hasPendingApprovals: params.hasPendingApprovals ?? false, |
| 135 | + hasPendingUserInput: params.hasPendingUserInput ?? false, |
| 136 | + hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, |
| 137 | + }, |
| 138 | + ], |
| 139 | + }; |
| 140 | +} |
| 141 | + |
68 | 142 | describe("retainThreadDetailSubscription", () => { |
69 | 143 | beforeEach(() => { |
70 | 144 | vi.useFakeTimers(); |
@@ -119,16 +193,89 @@ describe("retainThreadDetailSubscription", () => { |
119 | 193 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); |
120 | 194 |
|
121 | 195 | releaseSecond(); |
122 | | - await vi.advanceTimersByTimeAsync(2 * 60 * 1000 - 1); |
| 196 | + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); |
123 | 197 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); |
124 | 198 |
|
125 | | - await vi.advanceTimersByTimeAsync(1); |
| 199 | + await vi.advanceTimersByTimeAsync(28 * 60 * 1000); |
126 | 200 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); |
127 | 201 |
|
128 | 202 | stop(); |
129 | 203 | await resetEnvironmentServiceForTests(); |
130 | 204 | }); |
131 | 205 |
|
| 206 | + it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { |
| 207 | + const { |
| 208 | + retainThreadDetailSubscription, |
| 209 | + startEnvironmentConnectionService, |
| 210 | + resetEnvironmentServiceForTests, |
| 211 | + } = await import("./service"); |
| 212 | + |
| 213 | + const stop = startEnvironmentConnectionService(new QueryClient()); |
| 214 | + const environmentId = EnvironmentId.make("env-1"); |
| 215 | + const threadId = ThreadId.make("thread-active"); |
| 216 | + |
| 217 | + const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; |
| 218 | + expect(connectionInput).toBeDefined(); |
| 219 | + |
| 220 | + connectionInput.syncShellSnapshot( |
| 221 | + makeThreadShellSnapshot({ |
| 222 | + threadId, |
| 223 | + sessionStatus: "ready", |
| 224 | + hasPendingApprovals: true, |
| 225 | + }), |
| 226 | + environmentId, |
| 227 | + ); |
| 228 | + |
| 229 | + const release = retainThreadDetailSubscription(environmentId, threadId); |
| 230 | + expect(mockSubscribeThread).toHaveBeenCalledTimes(1); |
| 231 | + |
| 232 | + release(); |
| 233 | + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); |
| 234 | + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); |
| 235 | + |
| 236 | + connectionInput.applyShellEvent( |
| 237 | + { |
| 238 | + kind: "thread-upserted", |
| 239 | + sequence: 2, |
| 240 | + thread: makeThreadShellSnapshot({ |
| 241 | + threadId, |
| 242 | + sessionStatus: "idle", |
| 243 | + }).threads[0]!, |
| 244 | + }, |
| 245 | + environmentId, |
| 246 | + ); |
| 247 | + |
| 248 | + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); |
| 249 | + expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); |
| 250 | + |
| 251 | + stop(); |
| 252 | + await resetEnvironmentServiceForTests(); |
| 253 | + }); |
| 254 | + |
| 255 | + it("allows a larger idle cache before capacity eviction starts", async () => { |
| 256 | + const { |
| 257 | + retainThreadDetailSubscription, |
| 258 | + startEnvironmentConnectionService, |
| 259 | + resetEnvironmentServiceForTests, |
| 260 | + } = await import("./service"); |
| 261 | + |
| 262 | + const stop = startEnvironmentConnectionService(new QueryClient()); |
| 263 | + const environmentId = EnvironmentId.make("env-1"); |
| 264 | + |
| 265 | + for (let index = 0; index < 12; index += 1) { |
| 266 | + const release = retainThreadDetailSubscription( |
| 267 | + environmentId, |
| 268 | + ThreadId.make(`thread-${index + 1}`), |
| 269 | + ); |
| 270 | + release(); |
| 271 | + } |
| 272 | + |
| 273 | + expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); |
| 274 | + |
| 275 | + stop(); |
| 276 | + await resetEnvironmentServiceForTests(); |
| 277 | + }); |
| 278 | + |
132 | 279 | it("disposes cached thread detail subscriptions when the environment service resets", async () => { |
133 | 280 | const { |
134 | 281 | retainThreadDetailSubscription, |
|
0 commit comments