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("016_CanonicalizeModelSelections", (it) => {
12 it.effect(
13 "migrates legacy projection rows and event payloads to the canonical model-selection shape",
14 () =>
15 Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;
17
18 // Setup base state
19 {
20 yield* runMigrations({ toMigrationInclusive: 15 });
21
22 yield* sql`
23 INSERT INTO projection_projects (
24 project_id,
25 title,
26 workspace_root,
27 default_model,
28 scripts_json,
29 created_at,
30 updated_at,
31 deleted_at
32 )
33 VALUES
34 ('project-codex', 'Codex project', '/tmp/project-codex', 'gpt-5.4', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL),
35 ('project-claude', 'Claude project', '/tmp/project-claude', 'claude-sonnet-4-6', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL),
36 ('project-null', 'Null project', '/tmp/project-null', NULL, '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL)
37 `;
38 yield* sql`
39 UPDATE projection_projects
40 SET default_model = 'claude-opus-4-6'
41 WHERE project_id = 'project-claude'
42 `;
43 yield* sql`
44 INSERT INTO projection_threads (
45 thread_id,
46 project_id,
47 title,
48 model,
49 branch,
50 worktree_path,
51 latest_turn_id,
52 created_at,
53 updated_at,
54 deleted_at,
55 runtime_mode,
56 interaction_mode
57 )
58 VALUES
59 ('thread-session', 'project-codex', 'Session thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
60 ('thread-claude', 'project-claude', 'Claude thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
61 ('thread-codex', 'project-codex', 'Codex thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
62 ('thread-legacy-options', 'project-claude', 'Legacy options thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default')
63 `;
64 yield* sql`
65 INSERT INTO projection_thread_sessions (
66 thread_id,
67 status,
68 provider_name,
69 provider_session_id,
70 provider_thread_id,
71 active_turn_id,
72 last_error,
73 updated_at,
74 runtime_mode
75 )
76 VALUES (
77 'thread-session',
78 'running',
79 'claudeAgent',
80 'provider-session-1',
81 'provider-thread-1',
82 NULL,
83 NULL,
84 '2026-01-01T00:00:00.000Z',
85 'full-access'
86 )
87 `;
88 yield* sql`
89 INSERT INTO orchestration_events (
90 event_id,
91 aggregate_kind,
92 stream_id,
93 stream_version,
94 event_type,
95 occurred_at,
96 command_id,
97 causation_event_id,
98 correlation_id,
99 actor_kind,
100 payload_json,
101 metadata_json
102 )
103 VALUES
104 (
105 'event-project-created',
106 'project',
107 'project-1',
108 1,
109 'project.created',
110 '2026-01-01T00:00:00.000Z',
111 'command-project-created',
112 NULL,
113 'correlation-project-created',
114 'user',
115 '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
116 '{}'
117 ),
118 (
119 'event-project-created-fallback',
120 'project',
121 'project-2',
122 1,
123 'project.created',
124 '2026-01-01T00:00:00.000Z',
125 'command-project-created-fallback',
126 NULL,
127 'correlation-project-created-fallback',
128 'user',
129 '{"projectId":"project-2","title":"Fallback Project","workspaceRoot":"/tmp/project-2","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"low"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
130 '{}'
131 ),
132 (
133 'event-project-created-null-model',
134 'project',
135 'project-3',
136 1,
137 'project.created',
138 '2026-01-01T00:00:00.000Z',
139 'command-project-created-null-model',
140 NULL,
141 'correlation-project-created-null-model',
142 'user',
143 '{"projectId":"project-3","title":"Null Model Project","workspaceRoot":"/tmp/project-3","defaultModel":null,"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
144 '{}'
145 ),
146 (
147 'event-thread-created',
148 'thread',
149 'thread-1',
150 1,
151 'thread.created',
152 '2026-01-01T00:00:00.000Z',
153 'command-thread-created',
154 NULL,
155 'correlation-thread-created',
156 'user',
157 '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"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"}',
158 '{}'
159 ),
160 (
161 'event-thread-created-fallback',
162 'thread',
163 'thread-2',
164 1,
165 'thread.created',
166 '2026-01-01T00:00:00.000Z',
167 'command-thread-created-fallback',
168 NULL,
169 'correlation-thread-created-fallback',
170 'user',
171 '{"threadId":"thread-2","projectId":"project-1","title":"Fallback Thread","model":"gpt-5.4","modelOptions":{"claudeAgent":{"effort":"max"}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
172 '{}'
173 ),
174 (
175 'event-turn-start-requested',
176 'thread',
177 'thread-1',
178 2,
179 'thread.turn-start-requested',
180 '2026-01-01T00:00:00.000Z',
181 'command-turn-start-requested',
182 NULL,
183 'correlation-turn-start-requested',
184 'user',
185 '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}',
186 '{}'
187 ),
188 (
189 'event-thread-created-no-model',
190 'thread',
191 'thread-3',
192 1,
193 'thread.created',
194 '2026-01-01T00:00:00.000Z',
195 'command-thread-created-no-model',
196 NULL,
197 'correlation-thread-created-no-model',
198 'user',
199 '{"threadId":"thread-3","projectId":"project-1","title":"Ancient Thread","runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
200 '{}'
201 )
202 `;
203 }
204
205 // Execute migration under test
206 yield* runMigrations({ toMigrationInclusive: 16 });
207
208 // Assert expected state
209 {
210 const projectRows = yield* sql<{
211 readonly projectId: string;
212 readonly defaultModelSelection: string | null;
213 }>`
214 SELECT
215 project_id AS "projectId",
216 default_model_selection_json AS "defaultModelSelection"
217 FROM projection_projects
218 ORDER BY project_id
219 `;
220 assert.deepStrictEqual(projectRows, [
221 {
222 projectId: "project-claude",
223 defaultModelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
224 },
225 {
226 projectId: "project-codex",
227 defaultModelSelection: '{"provider":"codex","model":"gpt-5.4"}',
228 },
229 { projectId: "project-null", defaultModelSelection: null },
230 ]);
231
232 const threadRows = yield* sql<{
233 readonly threadId: string;
234 readonly modelSelection: string | null;
235 }>`
236 SELECT
237 thread_id AS "threadId",
238 model_selection_json AS "modelSelection"
239 FROM projection_threads
240 ORDER BY thread_id
241 `;
242 assert.deepStrictEqual(threadRows, [
243 {
244 threadId: "thread-claude",
245 modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
246 },
247 {
248 threadId: "thread-codex",
249 modelSelection: '{"provider":"codex","model":"gpt-5.4"}',
250 },
251 {
252 threadId: "thread-legacy-options",
253 modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
254 },
255 {
256 threadId: "thread-session",
257 modelSelection: '{"provider":"claudeAgent","model":"gpt-5.4"}',
258 },
259 ]);
260
261 const eventRows = yield* sql<{
262 readonly payloadJson: string;
263 }>`
264 SELECT payload_json AS "payloadJson"
265 FROM orchestration_events
266 ORDER BY rowid ASC
267 `;
268 assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), {
269 projectId: "project-1",
270 title: "Project",
271 workspaceRoot: "/tmp/project",
272 defaultModelSelection: {
273 provider: "claudeAgent",
274 model: "claude-opus-4-6",
275 options: {
276 effort: "max",
277 },
278 },
279 scripts: [],
280 createdAt: "2026-01-01T00:00:00.000Z",
281 updatedAt: "2026-01-01T00:00:00.000Z",
282 });
283 assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), {
284 projectId: "project-2",
285 title: "Fallback Project",
286 workspaceRoot: "/tmp/project-2",
287 defaultModelSelection: {
288 provider: "claudeAgent",
289 model: "claude-opus-4-6",
290 options: {
291 reasoningEffort: "low",
292 },
293 },
294 scripts: [],
295 createdAt: "2026-01-01T00:00:00.000Z",
296 updatedAt: "2026-01-01T00:00:00.000Z",
297 });
298 assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), {
299 projectId: "project-3",
300 title: "Null Model Project",
301 workspaceRoot: "/tmp/project-3",
302 defaultModelSelection: null,
303 scripts: [],
304 createdAt: "2026-01-01T00:00:00.000Z",
305 updatedAt: "2026-01-01T00:00:00.000Z",
306 });
307 assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), {
308 threadId: "thread-1",
309 projectId: "project-1",
310 title: "Thread",
311 modelSelection: {
312 provider: "claudeAgent",
313 model: "claude-opus-4-6",
314 options: {
315 effort: "max",
316 thinking: false,
317 },
318 },
319 runtimeMode: "full-access",
320 interactionMode: "default",
321 branch: null,
322 worktreePath: null,
323 createdAt: "2026-01-01T00:00:00.000Z",
324 updatedAt: "2026-01-01T00:00:00.000Z",
325 });
326 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {
327 threadId: "thread-2",
328 projectId: "project-1",
329 title: "Fallback Thread",
330 modelSelection: {
331 provider: "codex",
332 model: "gpt-5.4",
333 options: {
334 effort: "max",
335 },
336 },
337 runtimeMode: "full-access",
338 interactionMode: "default",
339 branch: null,
340 worktreePath: null,
341 createdAt: "2026-01-01T00:00:00.000Z",
342 updatedAt: "2026-01-01T00:00:00.000Z",
343 });
344 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {
345 threadId: "thread-1",
346 turnId: "turn-1",
347 input: "hi",
348 modelSelection: {
349 provider: "codex",
350 model: "gpt-5.4",
351 options: {
352 fastMode: true,
353 },
354 },
355 deliveryMode: "buffered",
356 });
357 assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), {
358 threadId: "thread-3",
359 projectId: "project-1",
360 title: "Ancient Thread",
361 modelSelection: {
362 provider: "codex",
363 model: "gpt-5.4",
364 },
365 runtimeMode: "full-access",
366 interactionMode: "default",
367 branch: null,
368 worktreePath: null,
369 createdAt: "2026-01-01T00:00:00.000Z",
370 updatedAt: "2026-01-01T00:00:00.000Z",
371 });
372 }
373 }),
374 );
375});
376
377layer("044_ClearAutomaticProjectModelDefaults", (it) => {
378 it.effect("clears create-time seeds and preserves explicit project defaults", () =>
379 Effect.gen(function* () {
380 const sql = yield* SqlClient.SqlClient;
381 yield* runMigrations({ toMigrationInclusive: 43 });
382
383 yield* sql`
384 INSERT INTO projection_projects (
385 project_id,
386 title,
387 workspace_root,
388 default_model_selection_json,
389 default_thread_env_mode,
390 favicon_path,
391 scripts_json,
392 created_at,
393 updated_at,
394 deleted_at
395 )
396 VALUES
397 ('project-auto', 'Auto', '/tmp/auto', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-01T00:00:00.000Z', NULL),
398 ('project-title-only', 'Title only', '/tmp/title-only', '{"instanceId":"codex","model":"gpt-5.6-sol"}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL),
399 ('project-explicit', 'Explicit', '/tmp/explicit', '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}', NULL, NULL, '[]', '2026-08-01T00:00:00.000Z', '2026-08-02T00:00:00.000Z', NULL)
400 `;
401
402 yield* sql`
403 INSERT INTO orchestration_events (
404 event_id,
405 aggregate_kind,
406 stream_id,
407 stream_version,
408 event_type,
409 occurred_at,
410 command_id,
411 causation_event_id,
412 correlation_id,
413 actor_kind,
414 payload_json,
415 metadata_json
416 )
417 VALUES
418 ('event-auto-create', 'project', 'project-auto', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-auto-create', NULL, 'command-auto-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
419 ('event-title-create', 'project', 'project-title-only', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-title-create', NULL, 'command-title-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
420 ('event-title-update', 'project', 'project-title-only', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-title-update', NULL, 'command-title-update', 'client', '{"title":"Renamed"}', '{}'),
421 ('event-explicit-create', 'project', 'project-explicit', 0, 'project.created', '2026-08-01T00:00:00.000Z', 'command-explicit-create', NULL, 'command-explicit-create', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol"}}', '{}'),
422 ('event-explicit-update', 'project', 'project-explicit', 1, 'project.meta-updated', '2026-08-02T00:00:00.000Z', 'command-explicit-update', NULL, 'command-explicit-update', 'client', '{"defaultModelSelection":{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}}', '{}')
423 `;
424
425 yield* runMigrations({ toMigrationInclusive: 44 });
426
427 const projects = yield* sql<{
428 readonly projectId: string;
429 readonly selection: string | null;
430 }>`
431 SELECT
432 project_id AS "projectId",
433 default_model_selection_json AS "selection"
434 FROM projection_projects
435 ORDER BY project_id
436 `;
437 assert.deepStrictEqual(projects, [
438 { projectId: "project-auto", selection: null },
439 {
440 projectId: "project-explicit",
441 selection:
442 '{"instanceId":"codex","model":"gpt-5.6-sol","options":[{"id":"reasoningEffort","value":"high"}]}',
443 },
444 { projectId: "project-title-only", selection: null },
445 ]);
446
447 const createdEvents = yield* sql<{
448 readonly streamId: string;
449 readonly model: string | null;
450 }>`
451 SELECT
452 stream_id AS "streamId",
453 json_extract(payload_json, '$.defaultModelSelection.model') AS "model"
454 FROM orchestration_events
455 WHERE event_type = 'project.created'
456 ORDER BY stream_id
457 `;
458 assert.deepStrictEqual(createdEvents, [
459 { streamId: "project-auto", model: null },
460 { streamId: "project-explicit", model: "gpt-5.6-sol" },
461 { streamId: "project-title-only", model: null },
462 ]);
463 }),
464 );
465});