1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
10
11layer("026_CanonicalizeModelSelectionOptions", (it) => {
12 it.effect("converts legacy object-shape options into array-shape on projections and events", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 25 });
17
18 yield* sql`
19 INSERT INTO projection_projects (
20 project_id,
21 title,
22 workspace_root,
23 default_model_selection_json,
24 scripts_json,
25 created_at,
26 updated_at,
27 deleted_at
28 )
29 VALUES
30 (
31 'project-legacy',
32 'Legacy options project',
33 '/tmp/legacy',
34 '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}}',
35 '[]',
36 '2026-01-01T00:00:00.000Z',
37 '2026-01-01T00:00:00.000Z',
38 NULL
39 ),
40 (
41 'project-no-options',
42 'No options project',
43 '/tmp/no-options',
44 '{"provider":"codex","model":"gpt-5.4"}',
45 '[]',
46 '2026-01-01T00:00:00.000Z',
47 '2026-01-01T00:00:00.000Z',
48 NULL
49 ),
50 (
51 'project-null-selection',
52 'Null model selection project',
53 '/tmp/null-selection',
54 NULL,
55 '[]',
56 '2026-01-01T00:00:00.000Z',
57 '2026-01-01T00:00:00.000Z',
58 NULL
59 ),
60 (
61 'project-already-array',
62 'Already-canonical options project',
63 '/tmp/already-array',
64 '{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"high"}]}',
65 '[]',
66 '2026-01-01T00:00:00.000Z',
67 '2026-01-01T00:00:00.000Z',
68 NULL
69 )
70 `;
71
72 yield* sql`
73 INSERT INTO projection_threads (
74 thread_id,
75 project_id,
76 title,
77 model_selection_json,
78 branch,
79 worktree_path,
80 latest_turn_id,
81 created_at,
82 updated_at,
83 archived_at,
84 latest_user_message_at,
85 pending_approval_count,
86 pending_user_input_count,
87 has_actionable_proposed_plan,
88 deleted_at,
89 runtime_mode,
90 interaction_mode
91 )
92 VALUES
93 (
94 'thread-legacy',
95 'project-legacy',
96 'Legacy thread',
97 '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false,"contextWindow":"1m"}}',
98 NULL, NULL, NULL,
99 '2026-01-01T00:00:00.000Z',
100 '2026-01-01T00:00:00.000Z',
101 NULL, NULL, 0, 0, 0, NULL,
102 'full-access', 'default'
103 ),
104 (
105 'thread-empty-options',
106 'project-legacy',
107 'Empty options thread',
108 '{"provider":"codex","model":"gpt-5.4","options":{}}',
109 NULL, NULL, NULL,
110 '2026-01-01T00:00:00.000Z',
111 '2026-01-01T00:00:00.000Z',
112 NULL, NULL, 0, 0, 0, NULL,
113 'full-access', 'default'
114 ),
115 (
116 'thread-drop-garbage',
117 'project-legacy',
118 'Thread with non-scalar entries',
119 '{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","thinking":{"enabled":true,"budgetTokens":2000},"emptyStr":" ","nullish":null}}',
120 NULL, NULL, NULL,
121 '2026-01-01T00:00:00.000Z',
122 '2026-01-01T00:00:00.000Z',
123 NULL, NULL, 0, 0, 0, NULL,
124 'full-access', 'default'
125 ),
126 (
127 'thread-no-options',
128 'project-legacy',
129 'No options thread',
130 '{"provider":"codex","model":"gpt-5.4"}',
131 NULL, NULL, NULL,
132 '2026-01-01T00:00:00.000Z',
133 '2026-01-01T00:00:00.000Z',
134 NULL, NULL, 0, 0, 0, NULL,
135 'full-access', 'default'
136 ),
137 (
138 'thread-already-array',
139 'project-legacy',
140 'Already array thread',
141 '{"provider":"codex","model":"gpt-5.4","options":[{"id":"fastMode","value":true}]}',
142 NULL, NULL, NULL,
143 '2026-01-01T00:00:00.000Z',
144 '2026-01-01T00:00:00.000Z',
145 NULL, NULL, 0, 0, 0, NULL,
146 'full-access', 'default'
147 )
148 `;
149
150 yield* sql`
151 INSERT INTO orchestration_events (
152 event_id,
153 aggregate_kind,
154 stream_id,
155 stream_version,
156 event_type,
157 occurred_at,
158 command_id,
159 causation_event_id,
160 correlation_id,
161 actor_kind,
162 payload_json,
163 metadata_json
164 )
165 VALUES
166 (
167 'event-project-created',
168 'project',
169 'project-legacy',
170 1,
171 'project.created',
172 '2026-01-01T00:00:00.000Z',
173 'cmd-pc',
174 NULL,
175 'corr-pc',
176 'user',
177 '{"projectId":"project-legacy","title":"Project","workspaceRoot":"/tmp/legacy","defaultModelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","fastMode":true}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
178 '{}'
179 ),
180 (
181 'event-project-meta-updated',
182 'project',
183 'project-legacy',
184 2,
185 'project.meta-updated',
186 '2026-01-01T00:00:00.000Z',
187 'cmd-pmu',
188 NULL,
189 'corr-pmu',
190 'user',
191 '{"projectId":"project-legacy","defaultModelSelection":{"provider":"codex","model":"gpt-5.4","options":{"reasoningEffort":"low"}},"updatedAt":"2026-01-01T00:00:00.000Z"}',
192 '{}'
193 ),
194 (
195 'event-project-null-selection',
196 'project',
197 'project-legacy',
198 3,
199 'project.meta-updated',
200 '2026-01-01T00:00:00.000Z',
201 'cmd-null',
202 NULL,
203 'corr-null',
204 'user',
205 '{"projectId":"project-legacy","defaultModelSelection":null,"updatedAt":"2026-01-01T00:00:00.000Z"}',
206 '{}'
207 ),
208 (
209 'event-thread-created',
210 'thread',
211 'thread-legacy',
212 1,
213 'thread.created',
214 '2026-01-01T00:00:00.000Z',
215 'cmd-tc',
216 NULL,
217 'corr-tc',
218 'user',
219 '{"threadId":"thread-legacy","projectId":"project-legacy","title":"Thread","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
220 '{}'
221 ),
222 (
223 'event-thread-meta-updated',
224 'thread',
225 'thread-legacy',
226 2,
227 'thread.meta-updated',
228 '2026-01-01T00:00:00.000Z',
229 'cmd-tmu',
230 NULL,
231 'corr-tmu',
232 'user',
233 '{"threadId":"thread-legacy","modelSelection":{"provider":"codex","model":"gpt-5.4","options":{"fastMode":true}},"updatedAt":"2026-01-01T00:00:00.000Z"}',
234 '{}'
235 ),
236 (
237 'event-thread-turn-start',
238 'thread',
239 'thread-legacy',
240 3,
241 'thread.turn-start-requested',
242 '2026-01-01T00:00:00.000Z',
243 'cmd-tts',
244 NULL,
245 'corr-tts',
246 'user',
247 '{"threadId":"thread-legacy","messageId":"msg-1","modelSelection":{"provider":"claudeAgent","model":"claude-opus-4-6","options":{"effort":"high","contextWindow":"1m"}},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-01-01T00:00:00.000Z"}',
248 '{}'
249 ),
250 (
251 'event-thread-already-array',
252 'thread',
253 'thread-legacy',
254 4,
255 'thread.created',
256 '2026-01-01T00:00:00.000Z',
257 'cmd-taa',
258 NULL,
259 'corr-taa',
260 'user',
261 '{"threadId":"thread-already-array","projectId":"project-legacy","title":"Already Array","modelSelection":{"provider":"codex","model":"gpt-5.4","options":[{"id":"reasoningEffort","value":"medium"}]},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
262 '{}'
263 ),
264 (
265 'event-activity-append',
266 'thread',
267 'thread-legacy',
268 5,
269 'thread.activity-appended',
270 '2026-01-01T00:00:00.000Z',
271 'cmd-aa',
272 NULL,
273 'corr-aa',
274 'user',
275 '{"threadId":"thread-legacy","activity":{"id":"a","tone":"info","kind":"k","summary":"s","payload":null,"turnId":null,"createdAt":"2026-01-01T00:00:00.000Z"}}',
276 '{}'
277 )
278 `;
279
280 yield* runMigrations({ toMigrationInclusive: 26 });
281
282 // Projection projects
283 const projectRows = yield* sql<{
284 readonly projectId: string;
285 readonly defaultModelSelection: string | null;
286 }>`
287 SELECT
288 project_id AS "projectId",
289 default_model_selection_json AS "defaultModelSelection"
290 FROM projection_projects
291 ORDER BY project_id
292 `;
293 assert.deepStrictEqual(
294 projectRows.map((row) => ({
295 projectId: row.projectId,
296 selection: row.defaultModelSelection ? JSON.parse(row.defaultModelSelection) : null,
297 })),
298 [
299 {
300 projectId: "project-already-array",
301 selection: {
302 provider: "codex",
303 model: "gpt-5.4",
304 options: [{ id: "reasoningEffort", value: "high" }],
305 },
306 },
307 {
308 projectId: "project-legacy",
309 selection: {
310 provider: "claudeAgent",
311 model: "claude-opus-4-6",
312 options: [
313 { id: "effort", value: "max" },
314 { id: "fastMode", value: true },
315 ],
316 },
317 },
318 {
319 projectId: "project-no-options",
320 selection: { provider: "codex", model: "gpt-5.4" },
321 },
322 { projectId: "project-null-selection", selection: null },
323 ],
324 );
325
326 // Projection threads
327 const threadRows = yield* sql<{
328 readonly threadId: string;
329 readonly modelSelection: string | null;
330 }>`
331 SELECT
332 thread_id AS "threadId",
333 model_selection_json AS "modelSelection"
334 FROM projection_threads
335 ORDER BY thread_id
336 `;
337 assert.deepStrictEqual(
338 threadRows.map((row) => ({
339 threadId: row.threadId,
340 selection: row.modelSelection ? JSON.parse(row.modelSelection) : null,
341 })),
342 [
343 {
344 threadId: "thread-already-array",
345 selection: {
346 provider: "codex",
347 model: "gpt-5.4",
348 options: [{ id: "fastMode", value: true }],
349 },
350 },
351 {
352 threadId: "thread-drop-garbage",
353 selection: {
354 provider: "claudeAgent",
355 model: "claude-opus-4-6",
356 // Only the scalar string survives; nested object, whitespace
357 // string, and null are dropped.
358 options: [{ id: "effort", value: "high" }],
359 },
360 },
361 {
362 threadId: "thread-empty-options",
363 selection: { provider: "codex", model: "gpt-5.4", options: [] },
364 },
365 {
366 threadId: "thread-legacy",
367 selection: {
368 provider: "claudeAgent",
369 model: "claude-opus-4-6",
370 options: [
371 { id: "effort", value: "max" },
372 { id: "thinking", value: false },
373 { id: "contextWindow", value: "1m" },
374 ],
375 },
376 },
377 {
378 threadId: "thread-no-options",
379 selection: { provider: "codex", model: "gpt-5.4" },
380 },
381 ],
382 );
383
384 // Orchestration events
385 const eventRows = yield* sql<{
386 readonly eventId: string;
387 readonly payloadJson: string;
388 }>`
389 SELECT event_id AS "eventId", payload_json AS "payloadJson"
390 FROM orchestration_events
391 ORDER BY event_id
392 `;
393
394 const payloads = Object.fromEntries(
395 eventRows.map((row) => [row.eventId, JSON.parse(row.payloadJson)]),
396 );
397
398 assert.deepStrictEqual(payloads["event-project-created"].defaultModelSelection, {
399 provider: "claudeAgent",
400 model: "claude-opus-4-6",
401 options: [
402 { id: "effort", value: "max" },
403 { id: "fastMode", value: true },
404 ],
405 });
406
407 assert.deepStrictEqual(payloads["event-project-meta-updated"].defaultModelSelection, {
408 provider: "codex",
409 model: "gpt-5.4",
410 options: [{ id: "reasoningEffort", value: "low" }],
411 });
412
413 assert.strictEqual(payloads["event-project-null-selection"].defaultModelSelection, null);
414
415 assert.deepStrictEqual(payloads["event-thread-created"].modelSelection, {
416 provider: "claudeAgent",
417 model: "claude-opus-4-6",
418 options: [
419 { id: "effort", value: "max" },
420 { id: "thinking", value: false },
421 ],
422 });
423
424 assert.deepStrictEqual(payloads["event-thread-meta-updated"].modelSelection, {
425 provider: "codex",
426 model: "gpt-5.4",
427 options: [{ id: "fastMode", value: true }],
428 });
429
430 assert.deepStrictEqual(payloads["event-thread-turn-start"].modelSelection, {
431 provider: "claudeAgent",
432 model: "claude-opus-4-6",
433 options: [
434 { id: "effort", value: "high" },
435 { id: "contextWindow", value: "1m" },
436 ],
437 });
438
439 // Already-array records are left untouched.
440 assert.deepStrictEqual(payloads["event-thread-already-array"].modelSelection, {
441 provider: "codex",
442 model: "gpt-5.4",
443 options: [{ id: "reasoningEffort", value: "medium" }],
444 });
445
446 // Events with no modelSelection at all are untouched.
447 assert.isUndefined(payloads["event-activity-append"].modelSelection);
448 assert.isUndefined(payloads["event-activity-append"].defaultModelSelection);
449 }),
450 );
451});