| @@ -1,5 +1,6 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| | 3 | EnvironmentId, |
| 3 | MessageId, | 4 | MessageId, |
| 4 | ProjectId, | 5 | ProjectId, |
| 5 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| @@ -14,7 +15,11 @@ import { McpSchema, McpServer } from "effect/unstable/ai"; · 8 unmodified lines |
| 14 | | 15 | |
| 15 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | 16 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 16 | import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; | 17 | import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 17 | import { buildThreadReferencePage, ThreadReferenceToolkitHandlersLive } from "./handlers.ts"; | 18 | import { |
| | 19 | buildThreadReferencePage, |
| | 20 | hasUserThreadReference, |
| | 21 | ThreadReferenceToolkitHandlersLive, |
| | 22 | } from "./handlers.ts"; |
| 18 | import { ThreadReferenceToolkit } from "./tools.ts"; | 23 | import { ThreadReferenceToolkit } from "./tools.ts"; |
| 19 | | 24 | |
| 20 | const TestLayer = McpServer.toolkit(ThreadReferenceToolkit).pipe( | 25 | const TestLayer = McpServer.toolkit(ThreadReferenceToolkit).pipe( |
| @@ -22,9 +27,13 @@ const TestLayer = McpServer.toolkit(ThreadReferenceToolkit).pipe( · 1 unmodified line |
| 22 | Layer.provideMerge(McpServer.McpServer.layer), | 27 | Layer.provideMerge(McpServer.McpServer.layer), |
| 23 | ); | 28 | ); |
| 24 | | 29 | |
| 25 | const makeMessage = (id: string, text: string): OrchestrationMessage => ({ | 30 | const makeMessage = ( |
| | 31 | id: string, |
| | 32 | text: string, |
| | 33 | role: OrchestrationMessage["role"] = "user", |
| | 34 | ): OrchestrationMessage => ({ |
| 26 | id: MessageId.make(id), | 35 | id: MessageId.make(id), |
| 27 | role: "user", | 36 | role, |
| 28 | text, | 37 | text, |
| 29 | turnId: null, | 38 | turnId: null, |
| 30 | streaming: false, | 39 | streaming: false, |
| @@ -32,12 +41,53 @@ const makeMessage = (id: string, text: string): OrchestrationMessage => ({ · 1 unmodified line |
| 32 | updatedAt: "2026-01-01T00:00:00.000Z", | 41 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 33 | }); | 42 | }); |
| 34 | | 43 | |
| | 44 | const currentThreadId = ThreadId.make("thread-current"); |
| | 45 | const environmentId = EnvironmentId.make("environment-1"); |
| 35 | const thread = { | 46 | const thread = { |
| 36 | id: ThreadId.make("thread-referenced"), | 47 | id: ThreadId.make("thread-referenced"), |
| 37 | projectId: ProjectId.make("project-1"), | 48 | projectId: ProjectId.make("project-1"), |
| 38 | title: "Referenced work", | 49 | title: "Referenced work", |
| 39 | messages: [makeMessage("message-1", "abcdef"), makeMessage("message-2", "ghijkl")], | 50 | messages: [makeMessage("message-1", "abcdef"), makeMessage("message-2", "ghijkl")], |
| 40 | } as unknown as OrchestrationThread; | 51 | } as unknown as OrchestrationThread; |
| | 52 | const currentThread = { |
| | 53 | ...thread, |
| | 54 | id: currentThreadId, |
| | 55 | messages: [ |
| | 56 | makeMessage( |
| | 57 | "message-reference", |
| | 58 | `[Referenced work](t3-thread:///${environmentId}/${thread.id})`, |
| | 59 | ), |
| | 60 | ], |
| | 61 | } as OrchestrationThread; |
| | 62 | const invocation = { |
| | 63 | environmentId, |
| | 64 | threadId: currentThreadId, |
| | 65 | providerSessionId: "provider-session-1", |
| | 66 | providerInstanceId: ProviderInstanceId.make("codex"), |
| | 67 | capabilities: new Set(["thread-reference"] as const), |
| | 68 | issuedAt: 1, |
| | 69 | expiresAt: Number.MAX_SAFE_INTEGER, |
| | 70 | }; |
| | 71 | const mcpServerClient = { |
| | 72 | clientId: 1, |
| | 73 | initializePayload: { |
| | 74 | protocolVersion: "2025-03-26", |
| | 75 | capabilities: {}, |
| | 76 | clientInfo: { name: "test", version: "1" }, |
| | 77 | }, |
| | 78 | getClient: Effect.die("unused"), |
| | 79 | }; |
| | 80 | const projectionQueryFor = (sourceThread: OrchestrationThread) => |
| | 81 | ({ |
| | 82 | getThreadDetailById: (threadId: ThreadId) => |
| | 83 | Effect.succeed( |
| | 84 | threadId === sourceThread.id |
| | 85 | ? Option.some(sourceThread) |
| | 86 | : threadId === thread.id |
| | 87 | ? Option.some(thread) |
| | 88 | : Option.none(), |
| | 89 | ), |
| | 90 | }) as never; |
| 41 | | 91 | |
| 42 | it("paginates within a message without losing transcript text", () => { | 92 | it("paginates within a message without losing transcript text", () => { |
| 43 | const first = buildThreadReferencePage(thread, { | 93 | const first = buildThreadReferencePage(thread, { |
| @@ -67,6 +117,67 @@ it("paginates within a message without losing transcript text", () => { · 23 unmodified lines |
| 67 | expect(longSecond.nextCursor).toBeNull(); | 117 | expect(longSecond.nextCursor).toBeNull(); |
| 68 | }); | 118 | }); |
| 69 | | 119 | |
| | 120 | it("continues pagination across an empty message at a page boundary", () => { |
| | 121 | const emptyMessageThread = { |
| | 122 | ...thread, |
| | 123 | messages: [ |
| | 124 | makeMessage("message-full", "a".repeat(1_000)), |
| | 125 | makeMessage("message-empty", ""), |
| | 126 | makeMessage("message-tail", "tail"), |
| | 127 | ], |
| | 128 | }; |
| | 129 | const first = buildThreadReferencePage(emptyMessageThread, { |
| | 130 | threadId: thread.id, |
| | 131 | maxChars: 1_000, |
| | 132 | }); |
| | 133 | if ("_tag" in first) throw new Error("unexpected cursor error"); |
| | 134 | expect(first.nextCursor).toBe("1:0"); |
| | 135 | |
| | 136 | const second = buildThreadReferencePage(emptyMessageThread, { |
| | 137 | threadId: thread.id, |
| | 138 | cursor: first.nextCursor!, |
| | 139 | maxChars: 1_000, |
| | 140 | }); |
| | 141 | if ("_tag" in second) throw new Error("unexpected cursor error"); |
| | 142 | expect(second.messages.map(({ text }) => text)).toEqual(["", "tail"]); |
| | 143 | expect(second.nextCursor).toBeNull(); |
| | 144 | }); |
| | 145 | |
| | 146 | it("only authorizes user-supplied references from the invoking environment", () => { |
| | 147 | expect(hasUserThreadReference(currentThread, environmentId, thread.id)).toBe(true); |
| | 148 | expect( |
| | 149 | hasUserThreadReference( |
| | 150 | { |
| | 151 | ...currentThread, |
| | 152 | messages: [ |
| | 153 | makeMessage( |
| | 154 | "message-assistant-reference", |
| | 155 | `[Referenced work](t3-thread:///${environmentId}/${thread.id})`, |
| | 156 | "assistant", |
| | 157 | ), |
| | 158 | ], |
| | 159 | }, |
| | 160 | environmentId, |
| | 161 | thread.id, |
| | 162 | ), |
| | 163 | ).toBe(false); |
| | 164 | expect( |
| | 165 | hasUserThreadReference( |
| | 166 | { |
| | 167 | ...currentThread, |
| | 168 | messages: [ |
| | 169 | makeMessage( |
| | 170 | "message-other-environment", |
| | 171 | `[Referenced work](t3-thread:///environment-2/${thread.id})`, |
| | 172 | ), |
| | 173 | ], |
| | 174 | }, |
| | 175 | environmentId, |
| | 176 | thread.id, |
| | 177 | ), |
| | 178 | ).toBe(false); |
| | 179 | }); |
| | 180 | |
| 70 | it.effect("reads a referenced thread through the MCP toolkit", () => | 181 | it.effect("reads a referenced thread through the MCP toolkit", () => |
| 71 | Effect.gen(function* () { | 182 | Effect.gen(function* () { |
| 72 | const server = yield* McpServer.McpServer; | 183 | const server = yield* McpServer.McpServer; |
| @@ -81,27 +192,31 @@ it.effect("reads a referenced thread through the MCP toolkit", () => · 8 unmodified lines |
| 81 | totalMessages: 2, | 192 | totalMessages: 2, |
| 82 | }); | 193 | }); |
| 83 | }).pipe( | 194 | }).pipe( |
| 84 | Effect.provideService(McpInvocationContext.McpInvocationContext, { | 195 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 85 | environmentId: "environment-1" as never, | 196 | Effect.provideService(McpSchema.McpServerClient, mcpServerClient), |
| 86 | threadId: ThreadId.make("thread-current"), | 197 | Effect.provideService(ProjectionSnapshotQuery, projectionQueryFor(currentThread)), |
| 87 | providerSessionId: "provider-session-1", | 198 | Effect.provide(TestLayer), |
| 88 | providerInstanceId: ProviderInstanceId.make("codex"), | 199 | ), |
| 89 | capabilities: new Set(["thread-reference"] as const), | 200 | ); |
| 90 | issuedAt: 1, | 201 | |
| 91 | expiresAt: Number.MAX_SAFE_INTEGER, | 202 | it.effect("rejects a thread that was not referenced by the invoking user", () => |
| 92 | }), | 203 | Effect.gen(function* () { |
| 93 | Effect.provideService(McpSchema.McpServerClient, { | 204 | const server = yield* McpServer.McpServer; |
| 94 | clientId: 1, | 205 | const result = yield* server.callTool({ |
| 95 | initializePayload: { | 206 | name: "thread_read", |
| 96 | protocolVersion: "2025-03-26", | 207 | arguments: { threadId: thread.id }, |
| 97 | capabilities: {}, | 208 | }); |
| 98 | clientInfo: { name: "test", version: "1" }, | 209 | expect(result.isError).toBe(true); |
| 99 | }, | 210 | }).pipe( |
| 100 | getClient: Effect.die("unused"), | 211 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), |
| 101 | }), | 212 | Effect.provideService(McpSchema.McpServerClient, mcpServerClient), |
| 102 | Effect.provideService(ProjectionSnapshotQuery, { | 213 | Effect.provideService( |
| 103 | getThreadDetailById: () => Effect.succeed(Option.some(thread)), | 214 | ProjectionSnapshotQuery, |
| 104 | } as never), | 215 | projectionQueryFor({ |
| | 216 | ...currentThread, |
| | 217 | messages: [makeMessage("message-without-reference", "No thread reference here.")], |
| | 218 | }), |
| | 219 | ), |
| 105 | Effect.provide(TestLayer), | 220 | Effect.provide(TestLayer), |
| 106 | ), | 221 | ), |
| 107 | ); | 222 | ); |