| 1 | import { |
| 2 | AuthSessionId, |
| 3 | type AuthEnvironmentScope, |
| 4 | EnvironmentAuthenticatedAuth, |
| 5 | EnvironmentAuthenticatedPrincipal, |
| 6 | EnvironmentHttpApi, |
| 7 | EnvironmentId, |
| 8 | EventId, |
| 9 | ORCHESTRATION_PROTOCOL_HEADER, |
| 10 | ORCHESTRATION_PROTOCOL_VERSION_TEXT, |
| 11 | OrchestrationV2AppThread, |
| 12 | OrchestrationV2TurnItem, |
| 13 | OrchestrationV2ThreadTranscript, |
| 14 | PROVIDER_SEND_TURN_MAX_FILE_BYTES, |
| 15 | ThreadId, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import { expect, it } from "@effect/vitest"; |
| 18 | import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as Context from "effect/Context"; |
| 21 | import * as DateTime from "effect/DateTime"; |
| 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 25 | import * as HttpPlatform from "effect/http/HttpPlatform"; |
| 26 | import * as Etag from "effect/http/Etag"; |
| 27 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 28 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 29 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 30 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 31 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 32 | import * as ServerSettings from "../serverSettings.ts"; |
| 33 | |
| 34 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 35 | import { ProjectEnrichmentService } from "../project/ProjectEnrichmentService.ts"; |
| 36 | import * as OrchestrationHttp from "./http.ts"; |
| 37 | import { EventSinkV2 } from "./EventSink.ts"; |
| 38 | import * as ProjectStore from "./ProjectStore.ts"; |
| 39 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
| 40 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 41 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 42 | import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; |
| 43 | |
| 44 | class TranscriptTestApi extends HttpApi.make("environment").add( |
| 45 | EnvironmentHttpApi.groups.orchestration, |
| 46 | ) {} |
| 47 | const decodeTranscript = Schema.decodeUnknownEffect( |
| 48 | Schema.toCodecJson(OrchestrationV2ThreadTranscript), |
| 49 | ); |
| 50 | |
| 51 | const now = DateTime.makeUnsafe("2026-09-29T00:00:00.000Z"); |
| 52 | const environmentId = EnvironmentId.make("source-بيئة"); |
| 53 | const output = "Full tool output. ".repeat(4_000); |
| 54 | const thread = Schema.decodeUnknownSync(OrchestrationV2AppThread)({ |
| 55 | id: "source-thread", |
| 56 | projectId: "source-project", |
| 57 | title: "Source 界\nconversation", |
| 58 | providerInstanceId: "codex", |
| 59 | modelSelection: { instanceId: "codex", model: "gpt-5.4" }, |
| 60 | runtimeMode: "full-access", |
| 61 | interactionMode: "default", |
| 62 | branch: null, |
| 63 | worktreePath: null, |
| 64 | activeProviderThreadId: null, |
| 65 | lineage: { rootThreadId: "source-thread", parentThreadId: null, relationshipToParent: null }, |
| 66 | forkedFrom: null, |
| 67 | createdBy: "user", |
| 68 | creationSource: "web", |
| 69 | createdAt: now, |
| 70 | updatedAt: now, |
| 71 | archivedAt: null, |
| 72 | settledOverride: null, |
| 73 | settledAt: null, |
| 74 | lastVisitedAt: null, |
| 75 | deletedAt: null, |
| 76 | }); |
| 77 | const items = Schema.decodeUnknownSync(Schema.Array(OrchestrationV2TurnItem))( |
| 78 | Array.from({ length: 200 }, (_, position) => ({ |
| 79 | id: `item-${position}`, |
| 80 | threadId: "source-thread", |
| 81 | runId: null, |
| 82 | nodeId: null, |
| 83 | providerThreadId: null, |
| 84 | providerTurnId: null, |
| 85 | nativeItemRef: null, |
| 86 | parentItemId: null, |
| 87 | ordinal: position, |
| 88 | type: "command_execution", |
| 89 | status: "completed", |
| 90 | title: "Read notes", |
| 91 | input: "cat notes.md", |
| 92 | output: position === 0 ? output : "Done", |
| 93 | startedAt: now, |
| 94 | completedAt: now, |
| 95 | updatedAt: now, |
| 96 | })), |
| 97 | ); |
| 98 | |
| 99 | it.effect("exports full history with size, scope, and missing-thread checks", () => |
| 100 | Effect.gen(function* () { |
| 101 | let allowed = true; |
| 102 | const runtime = ProviderReplayHarness.layerWithRegistry( |
| 103 | { name: "thread-transcript" }, |
| 104 | ProviderAdapterRegistry.layerFromAdapters([]), |
| 105 | { runEffectWorker: false }, |
| 106 | ); |
| 107 | const services = yield* Layer.build( |
| 108 | Layer.mergeAll( |
| 109 | runtime, |
| 110 | ThreadManagementService.layer.pipe( |
| 111 | Layer.provide( |
| 112 | Layer.mergeAll(runtime, CommandReceiptStore.layer, ServerSettings.layerTest({})), |
| 113 | ), |
| 114 | ), |
| 115 | OrchestrationEventStore.layer, |
| 116 | ProjectStore.layer, |
| 117 | ).pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), |
| 118 | ); |
| 119 | const eventSink = Context.get(services, EventSinkV2); |
| 120 | yield* eventSink.write({ |
| 121 | events: [ |
| 122 | { |
| 123 | id: EventId.make("create-source"), |
| 124 | type: "thread.created", |
| 125 | threadId: thread.id, |
| 126 | occurredAt: now, |
| 127 | payload: thread, |
| 128 | }, |
| 129 | ], |
| 130 | }); |
| 131 | const routes = HttpApiBuilder.layer(TranscriptTestApi).pipe( |
| 132 | Layer.provide(OrchestrationHttp.layer), |
| 133 | Layer.provide( |
| 134 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { |
| 135 | getEnvironmentId: Effect.succeed(environmentId), |
| 136 | }), |
| 137 | ), |
| 138 | Layer.provide( |
| 139 | Layer.succeed(EnvironmentAuthenticatedAuth, (effect) => |
| 140 | Effect.suspend(() => |
| 141 | effect.pipe( |
| 142 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| 143 | sessionId: AuthSessionId.make("test-session"), |
| 144 | subject: "test", |
| 145 | method: "bearer-access-token", |
| 146 | scopes: new Set<AuthEnvironmentScope>(allowed ? ["orchestration:read"] : []), |
| 147 | }), |
| 148 | ), |
| 149 | ), |
| 150 | ), |
| 151 | ), |
| 152 | Layer.provide(Layer.succeedContext(services)), |
| 153 | Layer.provide(Layer.mock(ProjectEnrichmentService)({})), |
| 154 | Layer.provide(HttpPlatform.layer), |
| 155 | Layer.provide(Etag.layerWeak), |
| 156 | Layer.provide(NodeServices.layer), |
| 157 | ); |
| 158 | const { handler, dispose } = HttpRouter.toWebHandler(routes, { disableLogger: true }); |
| 159 | yield* Effect.addFinalizer(() => Effect.promise(dispose)); |
| 160 | const read = (threadId: ThreadId) => |
| 161 | Effect.promise(() => |
| 162 | handler( |
| 163 | new Request(`http://localhost/api/orchestration/threads/${threadId}/transcript`, { |
| 164 | headers: { [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT }, |
| 165 | }), |
| 166 | ), |
| 167 | ); |
| 168 | |
| 169 | const emptyResponse = yield* read(thread.id); |
| 170 | expect(emptyResponse.status).toBe(200); |
| 171 | expect( |
| 172 | (yield* decodeTranscript(yield* Effect.promise(() => emptyResponse.json()))).items, |
| 173 | ).toEqual([]); |
| 174 | yield* eventSink.write({ |
| 175 | events: items.map((item) => ({ |
| 176 | id: EventId.make(`create-${item.id}`), |
| 177 | type: "turn-item.updated" as const, |
| 178 | threadId: thread.id, |
| 179 | occurredAt: now, |
| 180 | payload: item, |
| 181 | })), |
| 182 | }); |
| 183 | |
| 184 | const response = yield* read(thread.id); |
| 185 | expect(response.status).toBe(200); |
| 186 | const transcript = yield* decodeTranscript(yield* Effect.promise(() => response.json())); |
| 187 | expect(transcript.items).toHaveLength(200); |
| 188 | expect(transcript.items[0]?.item).toMatchObject({ output }); |
| 189 | expect(transcript.items[0]?.visibility).toBe("local"); |
| 190 | expect(transcript.items[199]?.position).toBe(199); |
| 191 | |
| 192 | const emptyOutputRows = transcript.items.map((row, position) => |
| 193 | position === 0 ? { ...row, item: { ...row.item, output: "" } } : row, |
| 194 | ); |
| 195 | const header = threadTranscriptHeader(environmentId, transcript); |
| 196 | const baseSize = |
| 197 | Buffer.byteLength(header, "utf8") + |
| 198 | emptyOutputRows.reduce( |
| 199 | (size, row) => size + Buffer.byteLength(`${JSON.stringify(row)}\n`, "utf8"), |
| 200 | 0, |
| 201 | ); |
| 202 | const boundaryOutput = "界" + "x".repeat(PROVIDER_SEND_TURN_MAX_FILE_BYTES - baseSize - 3); |
| 203 | for (const [suffix, expectedStatus] of [ |
| 204 | ["", 200], |
| 205 | ["x", 400], |
| 206 | ] as const) { |
| 207 | yield* eventSink.write({ |
| 208 | events: [ |
| 209 | { |
| 210 | id: EventId.make(`boundary-${expectedStatus}`), |
| 211 | type: "turn-item.updated", |
| 212 | threadId: thread.id, |
| 213 | occurredAt: now, |
| 214 | payload: { |
| 215 | ...items.find((item) => item.type === "command_execution")!, |
| 216 | output: boundaryOutput + suffix, |
| 217 | }, |
| 218 | }, |
| 219 | ], |
| 220 | }); |
| 221 | const response = yield* read(thread.id); |
| 222 | expect(response.status).toBe(expectedStatus); |
| 223 | if (expectedStatus === 400) { |
| 224 | const error = yield* Context.get(services, ThreadManagementService.ThreadManagementService) |
| 225 | .getThreadTranscript(thread.id) |
| 226 | .pipe( |
| 227 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { |
| 228 | getEnvironmentId: Effect.succeed(environmentId), |
| 229 | }), |
| 230 | Effect.flip, |
| 231 | ); |
| 232 | expect(error).toBeInstanceOf(ThreadManagementService.ThreadTranscriptTooLargeError); |
| 233 | expect(yield* Effect.promise(() => response.json())).toMatchObject({ |
| 234 | _tag: "EnvironmentRequestInvalidError", |
| 235 | reason: "thread_transcript_too_large", |
| 236 | }); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | expect((yield* read(ThreadId.make("missing"))).status).toBe(404); |
| 241 | allowed = false; |
| 242 | expect((yield* read(thread.id)).status).toBe(403); |
| 243 | }), |
| 244 | ); |