| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | |
| 4 | import { ThreadId } from "./baseSchemas.ts"; |
| 5 | import { |
| 6 | ProviderEvent, |
| 7 | ProviderSendTurnInput, |
| 8 | ProviderSession, |
| 9 | ProviderSessionStartInput, |
| 10 | ProviderUploadFeedbackError, |
| 11 | ProviderUploadFeedbackInput, |
| 12 | ProviderUploadFeedbackResult, |
| 13 | } from "./provider.ts"; |
| 14 | |
| 15 | const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); |
| 16 | const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); |
| 17 | const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession); |
| 18 | const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent); |
| 19 | const decodeProviderUploadFeedbackInput = Schema.decodeUnknownSync(ProviderUploadFeedbackInput); |
| 20 | const decodeProviderUploadFeedbackResult = Schema.decodeUnknownSync(ProviderUploadFeedbackResult); |
| 21 | |
| 22 | function getOptionValue( |
| 23 | options: ReadonlyArray<{ id: string; value: unknown }> | undefined, |
| 24 | id: string, |
| 25 | ): unknown { |
| 26 | return options?.find((option) => option.id === id)?.value; |
| 27 | } |
| 28 | |
| 29 | describe("ProviderSessionStartInput", () => { |
| 30 | it("accepts codex-compatible payloads", () => { |
| 31 | const parsed = decodeProviderSessionStartInput({ |
| 32 | threadId: "thread-1", |
| 33 | provider: "codex", |
| 34 | cwd: "/tmp/workspace", |
| 35 | modelSelection: { |
| 36 | provider: "codex", |
| 37 | model: "gpt-5.3-codex", |
| 38 | options: [ |
| 39 | { id: "reasoningEffort", value: "high" }, |
| 40 | { id: "fastMode", value: true }, |
| 41 | ], |
| 42 | }, |
| 43 | runtimeMode: "full-access", |
| 44 | }); |
| 45 | expect(parsed.runtimeMode).toBe("full-access"); |
| 46 | expect(parsed.modelSelection?.instanceId).toBe("codex"); |
| 47 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 48 | expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("high"); |
| 49 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 50 | }); |
| 51 | |
| 52 | it("rejects payloads without runtime mode", () => { |
| 53 | expect(() => |
| 54 | decodeProviderSessionStartInput({ |
| 55 | threadId: "thread-1", |
| 56 | provider: "codex", |
| 57 | }), |
| 58 | ).toThrow(); |
| 59 | }); |
| 60 | |
| 61 | it("accepts claude runtime knobs", () => { |
| 62 | const parsed = decodeProviderSessionStartInput({ |
| 63 | threadId: "thread-1", |
| 64 | provider: "claudeAgent", |
| 65 | cwd: "/tmp/workspace", |
| 66 | modelSelection: { |
| 67 | provider: "claudeAgent", |
| 68 | model: "claude-sonnet-4-6", |
| 69 | options: [ |
| 70 | { id: "thinking", value: true }, |
| 71 | { id: "effort", value: "max" }, |
| 72 | { id: "fastMode", value: true }, |
| 73 | ], |
| 74 | }, |
| 75 | runtimeMode: "full-access", |
| 76 | }); |
| 77 | expect(parsed.provider).toBe("claudeAgent"); |
| 78 | expect(parsed.modelSelection?.instanceId).toBe("claudeAgent"); |
| 79 | expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); |
| 80 | expect(getOptionValue(parsed.modelSelection?.options, "thinking")).toBe(true); |
| 81 | expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("max"); |
| 82 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 83 | expect(parsed.runtimeMode).toBe("full-access"); |
| 84 | }); |
| 85 | |
| 86 | it("accepts cursor provider", () => { |
| 87 | const parsed = decodeProviderSessionStartInput({ |
| 88 | threadId: "thread-1", |
| 89 | provider: "cursor", |
| 90 | cwd: "/tmp/workspace", |
| 91 | runtimeMode: "full-access", |
| 92 | modelSelection: { |
| 93 | provider: "cursor", |
| 94 | model: "composer-2", |
| 95 | options: [{ id: "fastMode", value: true }], |
| 96 | }, |
| 97 | }); |
| 98 | expect(parsed.provider).toBe("cursor"); |
| 99 | expect(parsed.modelSelection?.instanceId).toBe("cursor"); |
| 100 | expect(parsed.modelSelection?.model).toBe("composer-2"); |
| 101 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 102 | }); |
| 103 | |
| 104 | it("accepts fork-provided driver kinds as branded slugs", () => { |
| 105 | const parsed = decodeProviderSessionStartInput({ |
| 106 | threadId: "thread-1", |
| 107 | provider: "ollama", |
| 108 | providerInstanceId: "ollama_local", |
| 109 | cwd: "/tmp/workspace", |
| 110 | runtimeMode: "full-access", |
| 111 | modelSelection: { |
| 112 | instanceId: "ollama_local", |
| 113 | model: "llama3.3", |
| 114 | }, |
| 115 | }); |
| 116 | |
| 117 | expect(parsed.provider).toBe("ollama"); |
| 118 | expect(parsed.providerInstanceId).toBe("ollama_local"); |
| 119 | expect(parsed.modelSelection?.instanceId).toBe("ollama_local"); |
| 120 | }); |
| 121 | }); |
| 122 | |
| 123 | describe("ProviderSendTurnInput", () => { |
| 124 | it("accepts 100 attachments and rejects 101", () => { |
| 125 | const attachments = Array.from({ length: 100 }, (_, index) => ({ |
| 126 | type: "image", |
| 127 | id: `image-${index}`, |
| 128 | name: "image.png", |
| 129 | mimeType: "image/png", |
| 130 | sizeBytes: 1, |
| 131 | })); |
| 132 | expect( |
| 133 | decodeProviderSendTurnInput({ threadId: "thread-1", attachments }).attachments, |
| 134 | ).toHaveLength(100); |
| 135 | expect(() => |
| 136 | decodeProviderSendTurnInput({ |
| 137 | threadId: "thread-1", |
| 138 | attachments: [...attachments, attachments[0]], |
| 139 | }), |
| 140 | ).toThrow(); |
| 141 | }); |
| 142 | |
| 143 | it.each(["image", "file"])( |
| 144 | "caps total image bytes for %s attachments without charging videos", |
| 145 | (type) => { |
| 146 | const image = { |
| 147 | type, |
| 148 | id: "image", |
| 149 | name: "image.png", |
| 150 | mimeType: "image/png", |
| 151 | sizeBytes: 10 * 1024 * 1024, |
| 152 | }; |
| 153 | const video = { |
| 154 | type: "file", |
| 155 | id: "video", |
| 156 | name: "video.mp4", |
| 157 | mimeType: "video/mp4", |
| 158 | sizeBytes: 50 * 1024 * 1024, |
| 159 | }; |
| 160 | expect( |
| 161 | decodeProviderSendTurnInput({ |
| 162 | threadId: "thread-1", |
| 163 | attachments: [...Array.from({ length: 8 }, () => image), video], |
| 164 | }).attachments, |
| 165 | ).toHaveLength(9); |
| 166 | expect(() => |
| 167 | decodeProviderSendTurnInput({ |
| 168 | threadId: "thread-1", |
| 169 | attachments: [...Array.from({ length: 8 }, () => image), { ...image, sizeBytes: 1 }], |
| 170 | }), |
| 171 | ).toThrow(/80 MiB/); |
| 172 | }, |
| 173 | ); |
| 174 | |
| 175 | it("accepts codex modelSelection", () => { |
| 176 | const parsed = decodeProviderSendTurnInput({ |
| 177 | threadId: "thread-1", |
| 178 | modelSelection: { |
| 179 | provider: "codex", |
| 180 | model: "gpt-5.3-codex", |
| 181 | options: [ |
| 182 | { id: "reasoningEffort", value: "xhigh" }, |
| 183 | { id: "fastMode", value: true }, |
| 184 | ], |
| 185 | }, |
| 186 | }); |
| 187 | |
| 188 | expect(parsed.modelSelection?.instanceId).toBe("codex"); |
| 189 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 190 | expect(getOptionValue(parsed.modelSelection?.options, "reasoningEffort")).toBe("xhigh"); |
| 191 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 192 | }); |
| 193 | |
| 194 | it("accepts claude modelSelection including ultrathink", () => { |
| 195 | const parsed = decodeProviderSendTurnInput({ |
| 196 | threadId: "thread-1", |
| 197 | modelSelection: { |
| 198 | provider: "claudeAgent", |
| 199 | model: "claude-sonnet-4-6", |
| 200 | options: [ |
| 201 | { id: "effort", value: "ultrathink" }, |
| 202 | { id: "fastMode", value: true }, |
| 203 | ], |
| 204 | }, |
| 205 | }); |
| 206 | |
| 207 | expect(parsed.modelSelection?.instanceId).toBe("claudeAgent"); |
| 208 | expect(getOptionValue(parsed.modelSelection?.options, "effort")).toBe("ultrathink"); |
| 209 | expect(getOptionValue(parsed.modelSelection?.options, "fastMode")).toBe(true); |
| 210 | }); |
| 211 | }); |
| 212 | |
| 213 | describe("provider feedback", () => { |
| 214 | it("accepts a thread and an optional feedback reason", () => { |
| 215 | expect( |
| 216 | decodeProviderUploadFeedbackInput({ |
| 217 | threadId: "thread-1", |
| 218 | reason: "The agent stopped early.", |
| 219 | }), |
| 220 | ).toEqual({ threadId: "thread-1", reason: "The agent stopped early." }); |
| 221 | expect(decodeProviderUploadFeedbackInput({ threadId: "thread-1" })).toEqual({ |
| 222 | threadId: "thread-1", |
| 223 | }); |
| 224 | }); |
| 225 | |
| 226 | it("returns the shareable Codex feedback identifier", () => { |
| 227 | expect(decodeProviderUploadFeedbackResult({ feedbackId: "provider-thread-1" })).toEqual({ |
| 228 | feedbackId: "provider-thread-1", |
| 229 | }); |
| 230 | }); |
| 231 | |
| 232 | it("keeps the failed thread and original cause without exposing upstream text", () => { |
| 233 | const cause = new Error("provider request secret"); |
| 234 | const error = new ProviderUploadFeedbackError({ |
| 235 | threadId: ThreadId.make("thread-1"), |
| 236 | cause, |
| 237 | }); |
| 238 | |
| 239 | expect(error.threadId).toBe("thread-1"); |
| 240 | expect(error.cause).toBe(cause); |
| 241 | expect(error.message).toBe("Failed to upload feedback for thread thread-1."); |
| 242 | expect(error.message).not.toContain("provider request secret"); |
| 243 | }); |
| 244 | }); |
| 245 | |
| 246 | describe("providerInstanceId routing key (slice-2 invariant)", () => { |
| 247 | it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => { |
| 248 | const parsed = decodeProviderSessionStartInput({ |
| 249 | threadId: "thread-1", |
| 250 | provider: "codex", |
| 251 | runtimeMode: "full-access", |
| 252 | }); |
| 253 | expect(parsed.providerInstanceId).toBeUndefined(); |
| 254 | }); |
| 255 | |
| 256 | it("decodes a ProviderSessionStartInput with providerInstanceId (post-migration producer)", () => { |
| 257 | const parsed = decodeProviderSessionStartInput({ |
| 258 | threadId: "thread-1", |
| 259 | provider: "codex", |
| 260 | providerInstanceId: "codex_personal", |
| 261 | runtimeMode: "full-access", |
| 262 | }); |
| 263 | expect(parsed.providerInstanceId).toBe("codex_personal"); |
| 264 | }); |
| 265 | |
| 266 | it("propagates providerInstanceId through ProviderSession decode", () => { |
| 267 | const session = decodeProviderSession({ |
| 268 | provider: "codex", |
| 269 | providerInstanceId: "codex_work", |
| 270 | status: "ready", |
| 271 | runtimeMode: "full-access", |
| 272 | threadId: "thread-1", |
| 273 | createdAt: "2024-01-01T00:00:00Z", |
| 274 | updatedAt: "2024-01-01T00:00:00Z", |
| 275 | }); |
| 276 | expect(session.providerInstanceId).toBe("codex_work"); |
| 277 | }); |
| 278 | |
| 279 | it("decodes ProviderSession for fork-provided driver kinds", () => { |
| 280 | const session = decodeProviderSession({ |
| 281 | provider: "ollama", |
| 282 | providerInstanceId: "ollama_local", |
| 283 | status: "ready", |
| 284 | runtimeMode: "full-access", |
| 285 | threadId: "thread-1", |
| 286 | createdAt: "2024-01-01T00:00:00Z", |
| 287 | updatedAt: "2024-01-01T00:00:00Z", |
| 288 | }); |
| 289 | |
| 290 | expect(session.provider).toBe("ollama"); |
| 291 | expect(session.providerInstanceId).toBe("ollama_local"); |
| 292 | }); |
| 293 | |
| 294 | it("decodes a ProviderEvent carrying both legacy provider and new instance routing", () => { |
| 295 | const event = decodeProviderEvent({ |
| 296 | id: "event-1", |
| 297 | kind: "notification", |
| 298 | provider: "codex", |
| 299 | providerInstanceId: "codex_personal", |
| 300 | threadId: "thread-1", |
| 301 | createdAt: "2024-01-01T00:00:00Z", |
| 302 | method: "session.created", |
| 303 | }); |
| 304 | expect(event.provider).toBe("codex"); |
| 305 | expect(event.providerInstanceId).toBe("codex_personal"); |
| 306 | }); |
| 307 | |
| 308 | it("rejects providerInstanceId values that fail the slug pattern (defense in depth)", () => { |
| 309 | expect(() => |
| 310 | decodeProviderSessionStartInput({ |
| 311 | threadId: "thread-1", |
| 312 | provider: "codex", |
| 313 | providerInstanceId: "1bad", |
| 314 | runtimeMode: "full-access", |
| 315 | }), |
| 316 | ).toThrow(); |
| 317 | }); |
| 318 | }); |