| 1 | import { ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as SqlClient from "effect/sql/SqlClient"; |
| 7 | |
| 8 | import { runMigrations } from "../persistence/Migrations.ts"; |
| 9 | import * as McpAppModelContext from "./McpAppModelContext.ts"; |
| 10 | |
| 11 | const database = NodeSqliteClient.layer({ filename: ":memory:" }); |
| 12 | const layer = it.layer( |
| 13 | Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))), |
| 14 | ); |
| 15 | |
| 16 | const threadId = ThreadId.make("thread-context"); |
| 17 | const forkId = ThreadId.make("thread-context-fork"); |
| 18 | |
| 19 | /** An app's tool call in the projection, in a run with the given status. */ |
| 20 | const 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 | |
| 35 | layer("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 | }); |