1import {
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";
17import { expect, it } from "@effect/vitest";
18import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript";
19import * as Effect from "effect/Effect";
20import * as Context from "effect/Context";
21import * as DateTime from "effect/DateTime";
22import * as Layer from "effect/Layer";
23import * as Schema from "effect/Schema";
24import * as HttpRouter from "effect/http/HttpRouter";
25import * as HttpPlatform from "effect/http/HttpPlatform";
26import * as Etag from "effect/http/Etag";
27import * as NodeServices from "@effect/platform-node/NodeServices";
28import * as HttpApi from "effect/http-api/HttpApi";
29import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
30import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
31import * as SqlitePersistence from "../persistence/Sqlite.ts";
32import * as ServerSettings from "../serverSettings.ts";
33
34import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
35import { ProjectEnrichmentService } from "../project/ProjectEnrichmentService.ts";
36import * as OrchestrationHttp from "./http.ts";
37import { EventSinkV2 } from "./EventSink.ts";
38import * as ProjectStore from "./ProjectStore.ts";
39import * as CommandReceiptStore from "./CommandReceiptStore.ts";
40import * as ThreadManagementService from "./ThreadManagementService.ts";
41import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
42import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
43
44class TranscriptTestApi extends HttpApi.make("environment").add(
45 EnvironmentHttpApi.groups.orchestration,
46) {}
47const decodeTranscript = Schema.decodeUnknownEffect(
48 Schema.toCodecJson(OrchestrationV2ThreadTranscript),
49);
50
51const now = DateTime.makeUnsafe("2026-09-29T00:00:00.000Z");
52const environmentId = EnvironmentId.make("source-بيئة");
53const output = "Full tool output. ".repeat(4_000);
54const 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});
77const 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
99it.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);