1import { ThreadId, TurnItemId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as SqlClient from "effect/sql/SqlClient";
7
8import { runMigrations } from "../persistence/Migrations.ts";
9import * as McpAppModelContext from "./McpAppModelContext.ts";
10
11const database = NodeSqliteClient.layer({ filename: ":memory:" });
12const layer = it.layer(
13 Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))),
14);
15
16const threadId = ThreadId.make("thread-context");
17const forkId = ThreadId.make("thread-context-fork");
18
19/** An app's tool call in the projection, in a run with the given status. */
20const insertAppItem = (itemId: string, runId: string, runStatus: string) =>
21 Effect.gen(function* () {
22 const sql = yield* SqlClient.SqlClient;
23 yield* sql`
24 INSERT INTO orchestration_v2_projection_runs
25 (run_id, thread_id, ordinal, provider, status, requested_at, payload_json)
26 VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}')
27 `;
28 yield* sql`
29 INSERT INTO orchestration_v2_projection_turn_items
30 (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json)
31 VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}')
32 `;
33 });
34
35layer("McpAppModelContext", (it) => {
36 it.effect("sends only apps still in the thread's history, including a fork's", () =>
37 Effect.gen(function* () {
38 yield* runMigrations();
39 const store = yield* McpAppModelContext.McpAppModelContext;
40 yield* insertAppItem("item-kept", "run-a", "completed");
41 yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back");
42 const set = (thread: ThreadId, itemId: string, text: string) =>
43 store.set({
44 threadId: thread,
45 itemId: TurnItemId.make(itemId),
46 server: "todos",
47 tool: "list_todos",
48 text,
49 });
50 yield* set(threadId, "item-kept", "kept");
51 yield* set(threadId, "item-rolled-back", "from a rolled-back run");
52 yield* set(threadId, "item-deleted", "from an item that is gone");
53 // A fork showing the source thread's app keeps its own context, even
54 // after the source rolls that app's run back.
55 yield* set(forkId, "item-kept", "from the fork");
56 yield* set(forkId, "item-rolled-back", "inherited before the rollback");
57
58 const texts = (thread: ThreadId) =>
59 store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text)));
60 assert.deepEqual(yield* texts(threadId), ["kept"]);
61 assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]);
62
63 // Clearing removes the app's context.
64 yield* set(threadId, "item-kept", "");
65 assert.deepEqual(yield* texts(threadId), []);
66 }),
67 );
68});