1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import { ThreadId } from "./baseSchemas.ts";
5import {
6 ProviderEvent,
7 ProviderSendTurnInput,
8 ProviderSession,
9 ProviderSessionStartInput,
10 ProviderUploadFeedbackError,
11 ProviderUploadFeedbackInput,
12 ProviderUploadFeedbackResult,
13} from "./provider.ts";
14
15const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
16const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
17const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession);
18const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent);
19const decodeProviderUploadFeedbackInput = Schema.decodeUnknownSync(ProviderUploadFeedbackInput);
20const decodeProviderUploadFeedbackResult = Schema.decodeUnknownSync(ProviderUploadFeedbackResult);
21
22function 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
29describe("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
123describe("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
213describe("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
246describe("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});