| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { deriveProviderInstanceEntries } from "../../providerInstances"; |
| 4 | import { |
| 5 | formatContextWindowCompactionMessage, |
| 6 | hasAvailableCompactionProvider, |
| 7 | hasDismissedResumeCompaction, |
| 8 | formatContextWindowCost, |
| 9 | resolveContextWindowModelDisplayName, |
| 10 | shouldOfferResumeCompaction, |
| 11 | shouldReserveContextWindowMeter, |
| 12 | } from "./ContextWindowMeter.logic"; |
| 13 | |
| 14 | function claudeProvider(input: { |
| 15 | instanceId: string; |
| 16 | continuationGroupKey: string; |
| 17 | enabled?: boolean; |
| 18 | }): ServerProvider { |
| 19 | return { |
| 20 | instanceId: ProviderInstanceId.make(input.instanceId), |
| 21 | driver: ProviderDriverKind.make("claudeAgent"), |
| 22 | continuation: { groupKey: input.continuationGroupKey }, |
| 23 | enabled: input.enabled ?? true, |
| 24 | installed: true, |
| 25 | version: null, |
| 26 | status: "ready", |
| 27 | auth: { status: "authenticated" }, |
| 28 | checkedAt: "2026-08-24T12:00:00.000Z", |
| 29 | models: [], |
| 30 | slashCommands: [{ name: "compact", description: "" }], |
| 31 | skills: [], |
| 32 | }; |
| 33 | } |
| 34 | |
| 35 | describe("hasAvailableCompactionProvider", () => { |
| 36 | const originalInstanceId = ProviderInstanceId.make("claude_original"); |
| 37 | |
| 38 | it("rejects a fallback in a different locked continuation group", () => { |
| 39 | const providers = deriveProviderInstanceEntries([ |
| 40 | claudeProvider({ |
| 41 | instanceId: originalInstanceId, |
| 42 | continuationGroupKey: "claude:home:/original", |
| 43 | enabled: false, |
| 44 | }), |
| 45 | claudeProvider({ |
| 46 | instanceId: "claude_other", |
| 47 | continuationGroupKey: "claude:home:/other", |
| 48 | }), |
| 49 | ]); |
| 50 | |
| 51 | expect( |
| 52 | hasAvailableCompactionProvider({ |
| 53 | providers, |
| 54 | driverKind: ProviderDriverKind.make("claudeAgent"), |
| 55 | instanceId: originalInstanceId, |
| 56 | lockedInstanceId: originalInstanceId, |
| 57 | }), |
| 58 | ).toBe(false); |
| 59 | }); |
| 60 | |
| 61 | it("accepts an enabled fallback in the locked continuation group", () => { |
| 62 | const providers = deriveProviderInstanceEntries([ |
| 63 | claudeProvider({ |
| 64 | instanceId: originalInstanceId, |
| 65 | continuationGroupKey: "claude:home:/original", |
| 66 | enabled: false, |
| 67 | }), |
| 68 | claudeProvider({ |
| 69 | instanceId: "claude_fallback", |
| 70 | continuationGroupKey: "claude:home:/original", |
| 71 | }), |
| 72 | ]); |
| 73 | |
| 74 | expect( |
| 75 | hasAvailableCompactionProvider({ |
| 76 | providers, |
| 77 | driverKind: ProviderDriverKind.make("claudeAgent"), |
| 78 | instanceId: originalInstanceId, |
| 79 | lockedInstanceId: originalInstanceId, |
| 80 | }), |
| 81 | ).toBe(true); |
| 82 | }); |
| 83 | }); |
| 84 | |
| 85 | describe("resolveContextWindowModelDisplayName", () => { |
| 86 | it("uses the selected model from the exact provider instance", () => { |
| 87 | const primaryInstanceId = ProviderInstanceId.make("codex"); |
| 88 | const selectedInstanceId = ProviderInstanceId.make("codex-work"); |
| 89 | const modelOptionsByInstance = new Map([ |
| 90 | [ |
| 91 | primaryInstanceId, |
| 92 | [{ slug: "gpt-5.6-sol", name: "Primary profile model", shortName: "Primary" }], |
| 93 | ], |
| 94 | [selectedInstanceId, [{ slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", shortName: "5.6 Sol" }]], |
| 95 | ]); |
| 96 | |
| 97 | expect( |
| 98 | resolveContextWindowModelDisplayName( |
| 99 | { |
| 100 | instanceId: selectedInstanceId, |
| 101 | model: "gpt-5.6-sol", |
| 102 | }, |
| 103 | modelOptionsByInstance, |
| 104 | ), |
| 105 | ).toBe("5.6 Sol"); |
| 106 | }); |
| 107 | |
| 108 | it("falls back to the selected model slug when model metadata is unavailable", () => { |
| 109 | const selectedInstanceId = ProviderInstanceId.make("codex-work"); |
| 110 | |
| 111 | expect( |
| 112 | resolveContextWindowModelDisplayName( |
| 113 | { |
| 114 | instanceId: selectedInstanceId, |
| 115 | model: "custom-model", |
| 116 | }, |
| 117 | new Map(), |
| 118 | ), |
| 119 | ).toBe("custom-model"); |
| 120 | }); |
| 121 | }); |
| 122 | |
| 123 | describe("formatContextWindowCompactionMessage", () => { |
| 124 | it("describes compaction in terms of the selected model", () => { |
| 125 | expect(formatContextWindowCompactionMessage("GPT-5.6 Sol")).toBe( |
| 126 | "Context for GPT-5.6 Sol compacts automatically when needed.", |
| 127 | ); |
| 128 | }); |
| 129 | |
| 130 | it("uses neutral copy when the model is unavailable", () => { |
| 131 | expect(formatContextWindowCompactionMessage(null)).toBe( |
| 132 | "Context compacts automatically when needed.", |
| 133 | ); |
| 134 | }); |
| 135 | |
| 136 | it("shows the configured auto-compaction threshold", () => { |
| 137 | expect(formatContextWindowCompactionMessage("Claude Sonnet 5", 300_000)).toBe( |
| 138 | "Compacts automatically at 300,000 tokens.", |
| 139 | ); |
| 140 | }); |
| 141 | }); |
| 142 | |
| 143 | describe("shouldOfferResumeCompaction", () => { |
| 144 | const now = "2026-08-24T12:00:00.000Z"; |
| 145 | |
| 146 | it("matches Claude's old-session age and context thresholds", () => { |
| 147 | expect( |
| 148 | shouldOfferResumeCompaction({ |
| 149 | provider: "claudeAgent", |
| 150 | usedTokens: 100_000, |
| 151 | updatedAt: "2026-08-24T10:50:00.000Z", |
| 152 | now, |
| 153 | }), |
| 154 | ).toBe(true); |
| 155 | }); |
| 156 | |
| 157 | it("does not prompt for recent or smaller sessions", () => { |
| 158 | expect( |
| 159 | shouldOfferResumeCompaction({ |
| 160 | provider: "claudeAgent", |
| 161 | usedTokens: 99_999, |
| 162 | updatedAt: "2026-08-24T10:00:00.000Z", |
| 163 | now, |
| 164 | }), |
| 165 | ).toBe(false); |
| 166 | expect( |
| 167 | shouldOfferResumeCompaction({ |
| 168 | provider: "claudeAgent", |
| 169 | usedTokens: 200_000, |
| 170 | updatedAt: "2026-08-24T10:51:00.000Z", |
| 171 | now, |
| 172 | }), |
| 173 | ).toBe(false); |
| 174 | }); |
| 175 | |
| 176 | it("uses the reported prompt cache TTL instead of the fallback", () => { |
| 177 | const offer = (updatedAt: string) => |
| 178 | shouldOfferResumeCompaction({ |
| 179 | provider: "claudeAgent", |
| 180 | usedTokens: 200_000, |
| 181 | updatedAt, |
| 182 | promptCacheTtlMs: 5 * 60_000, |
| 183 | now, |
| 184 | }); |
| 185 | expect(offer("2026-08-24T11:55:00.000Z")).toBe(true); |
| 186 | expect(offer("2026-08-24T11:56:00.000Z")).toBe(false); |
| 187 | }); |
| 188 | |
| 189 | it("does not show Claude's resume prompt for another provider", () => { |
| 190 | expect( |
| 191 | shouldOfferResumeCompaction({ |
| 192 | provider: "codex", |
| 193 | usedTokens: 300_000, |
| 194 | updatedAt: "2026-08-24T09:00:00.000Z", |
| 195 | now, |
| 196 | }), |
| 197 | ).toBe(false); |
| 198 | }); |
| 199 | }); |
| 200 | |
| 201 | describe("hasDismissedResumeCompaction", () => { |
| 202 | it("recognizes the native resume dialog's permanent dismissal", () => { |
| 203 | expect( |
| 204 | hasDismissedResumeCompaction([ |
| 205 | { |
| 206 | kind: "user-input.resolved", |
| 207 | payload: { |
| 208 | answers: { |
| 209 | "This session is 2h 0m old and uses 250,000 tokens. Compact it before continuing?": |
| 210 | "Don't ask again", |
| 211 | }, |
| 212 | }, |
| 213 | }, |
| 214 | ]), |
| 215 | ).toBe(true); |
| 216 | }); |
| 217 | |
| 218 | it("ignores the same answer on an unrelated question", () => { |
| 219 | expect( |
| 220 | hasDismissedResumeCompaction([ |
| 221 | { |
| 222 | kind: "user-input.resolved", |
| 223 | payload: { answers: { "Show this setup reminder?": "Don't ask again" } }, |
| 224 | }, |
| 225 | ]), |
| 226 | ).toBe(false); |
| 227 | }); |
| 228 | |
| 229 | it("ignores unrelated questions that end with Claude's compaction prompt", () => { |
| 230 | expect( |
| 231 | hasDismissedResumeCompaction([ |
| 232 | { |
| 233 | kind: "user-input.resolved", |
| 234 | payload: { |
| 235 | answers: { |
| 236 | "The build cache is large. Compact it before continuing?": "Don't ask again", |
| 237 | }, |
| 238 | }, |
| 239 | }, |
| 240 | ]), |
| 241 | ).toBe(false); |
| 242 | }); |
| 243 | |
| 244 | it("ignores pending questions and malformed resolved payloads", () => { |
| 245 | expect( |
| 246 | hasDismissedResumeCompaction([ |
| 247 | { kind: "user-input.requested", payload: { answers: { question: "Don't ask again" } } }, |
| 248 | { kind: "user-input.resolved", payload: null }, |
| 249 | { kind: "user-input.resolved", payload: { answers: ["Don't ask again"] } }, |
| 250 | ]), |
| 251 | ).toBe(false); |
| 252 | }); |
| 253 | }); |
| 254 | |
| 255 | describe("shouldReserveContextWindowMeter", () => { |
| 256 | const loadingStartedThread = { |
| 257 | meterEnabled: true, |
| 258 | detailLoading: true, |
| 259 | threadStarted: true, |
| 260 | providerReportsContextWindow: true, |
| 261 | }; |
| 262 | |
| 263 | it("holds the meter's slot while a started thread's detail loads", () => { |
| 264 | expect(shouldReserveContextWindowMeter(loadingStartedThread)).toBe(true); |
| 265 | }); |
| 266 | |
| 267 | it("reserves nothing once the detail is in", () => { |
| 268 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, detailLoading: false })).toBe( |
| 269 | false, |
| 270 | ); |
| 271 | }); |
| 272 | |
| 273 | it("reserves nothing for a thread that never ran a turn", () => { |
| 274 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, threadStarted: false })).toBe( |
| 275 | false, |
| 276 | ); |
| 277 | }); |
| 278 | |
| 279 | it("reserves while the thread's provider is not in the catalog yet", () => { |
| 280 | expect( |
| 281 | shouldReserveContextWindowMeter({ |
| 282 | ...loadingStartedThread, |
| 283 | providerReportsContextWindow: null, |
| 284 | }), |
| 285 | ).toBe(true); |
| 286 | }); |
| 287 | |
| 288 | it("reserves nothing for a provider that does not stream usage", () => { |
| 289 | expect( |
| 290 | shouldReserveContextWindowMeter({ |
| 291 | ...loadingStartedThread, |
| 292 | providerReportsContextWindow: false, |
| 293 | }), |
| 294 | ).toBe(false); |
| 295 | }); |
| 296 | |
| 297 | it("reserves nothing while the meter is switched off", () => { |
| 298 | expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, meterEnabled: false })).toBe( |
| 299 | false, |
| 300 | ); |
| 301 | }); |
| 302 | }); |
| 303 | |
| 304 | describe("formatContextWindowCost", () => { |
| 305 | it("keeps ordinary and sub-cent ACP costs readable", () => { |
| 306 | expect(formatContextWindowCost({ amount: 0.42, currency: "USD" })).toBe("USD 0.42"); |
| 307 | expect(formatContextWindowCost({ amount: 0.0042, currency: "USD" })).toBe("USD 0.0042"); |
| 308 | }); |
| 309 | }); |