|
| 1 | +// @effect-diagnostics nodeBuiltinImport:off - The mock ACP agent logs its requests to a temporary file. |
| 2 | +// Buddy's prompts through the production V2 runtime, as Buddy's T3 Code builds it: the provider |
| 3 | +// instance `buddy` of the ACP registry runs the repository's mock ACP agent in Hermes' place, and |
| 4 | +// the agent logs every request it receives. |
| 5 | +import * as NodeFS from "node:fs"; |
| 6 | +import * as NodeOS from "node:os"; |
| 7 | +import * as NodePath from "node:path"; |
| 8 | + |
| 9 | +import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | +import { assert, describe, it } from "@effect/vitest"; |
| 11 | +import { |
| 12 | + CommandId, |
| 13 | + type ComposerContextRecord, |
| 14 | + EnvironmentId, |
| 15 | + EventId, |
| 16 | + MessageId, |
| 17 | + type ModelSelection, |
| 18 | + ProjectId, |
| 19 | + ProviderDriverKind, |
| 20 | + ProviderInstanceId, |
| 21 | + ThreadId, |
| 22 | +} from "@t3tools/contracts"; |
| 23 | +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 24 | +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 25 | +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 26 | +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; |
| 27 | +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; |
| 28 | +import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 29 | +import * as Effect from "effect/Effect"; |
| 30 | +import * as Layer from "effect/Layer"; |
| 31 | +import * as Stream from "effect/Stream"; |
| 32 | +import { FetchHttpClient } from "effect/http"; |
| 33 | + |
| 34 | +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 35 | +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 36 | +import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; |
| 37 | +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 38 | +import * as ServerConfig from "../config.ts"; |
| 39 | +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 40 | +import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; |
| 41 | +import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; |
| 42 | +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 43 | +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; |
| 44 | +import * as ProviderTurnStartServiceTestkit from "../orchestration-v2/ProviderTurnStartService.testkit.ts"; |
| 45 | +import { isTerminalRunStatus } from "../orchestration-v2/ThreadManagementService.ts"; |
| 46 | +import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts"; |
| 47 | +import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 48 | +import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; |
| 49 | +import * as CodexInstallation from "../provider/CodexInstallation.ts"; |
| 50 | +import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts"; |
| 51 | +import * as ModelManifest from "../provider/ModelManifest.ts"; |
| 52 | +import * as PortScanner from "../preview/PortScanner.ts"; |
| 53 | +import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; |
| 54 | +import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; |
| 55 | +import * as ServerSettings from "../serverSettings.ts"; |
| 56 | +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 57 | +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 58 | +import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 59 | +import { lazurioPageContextBlock, withBuddyInstance } from "./buddyPrompt.ts"; |
| 60 | + |
| 61 | +const BUDDY = ProviderInstanceId.make("buddy"); |
| 62 | +const buddyModel = { instanceId: BUDDY, model: "default" } satisfies ModelSelection; |
| 63 | +const requestLog = NodePath.join( |
| 64 | + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "buddy-prompt-orchestrator-")), |
| 65 | + "requests.jsonl", |
| 66 | +); |
| 67 | +const page = { |
| 68 | + v: 1, |
| 69 | + environment: { id: "vm-01.example", kind: "work", name: "Pracovní" }, |
| 70 | + organization: { slug: "example", name: "Example Company" }, |
| 71 | + app: "chat", |
| 72 | + page: { path: "/a1b2c3" }, |
| 73 | +} as const; |
| 74 | + |
| 75 | +const layerPlatformTest = Layer.merge( |
| 76 | + NodeServices.layer, |
| 77 | + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ |
| 78 | + resolveLink: () => Effect.die("unused title link"), |
| 79 | + }), |
| 80 | +); |
| 81 | +const layerServerConfig = ServerConfig.layerTest(process.cwd(), { |
| 82 | + prefix: "t3-lazurio-buddy-prompt-", |
| 83 | +}); |
| 84 | +const layerServerSettings = ServerSettings.layerTest({ |
| 85 | + providerInstances: { |
| 86 | + [BUDDY]: { |
| 87 | + driver: ProviderDriverKind.make("acpRegistry"), |
| 88 | + displayName: "Buddy", |
| 89 | + enabled: true, |
| 90 | + config: { |
| 91 | + source: "local", |
| 92 | + commandPath: process.execPath, |
| 93 | + commandArgs: [NodePath.join(import.meta.dirname, "../../scripts/acp-mock-agent.ts")], |
| 94 | + }, |
| 95 | + }, |
| 96 | + }, |
| 97 | +}); |
| 98 | +const layerCheckpointStore = CheckpointStore.layer.pipe( |
| 99 | + Layer.provide( |
| 100 | + VcsDriverRegistry.layer.pipe( |
| 101 | + Layer.provide(VcsProcess.layer), |
| 102 | + Layer.provide(layerServerConfig), |
| 103 | + Layer.provide(layerPlatformTest), |
| 104 | + ), |
| 105 | + ), |
| 106 | +); |
| 107 | +// Wrapped as server.ts wraps it. |
| 108 | +const layerProviderInstanceRegistry = withBuddyInstance( |
| 109 | + ProviderInstanceRegistryHydration.layer, |
| 110 | +).pipe( |
| 111 | + Layer.provide( |
| 112 | + Layer.mergeAll( |
| 113 | + layerServerConfig.pipe(Layer.provide(layerPlatformTest)), |
| 114 | + layerServerSettings, |
| 115 | + ServerSecretStore.layer.pipe( |
| 116 | + Layer.provide(layerServerConfig), |
| 117 | + Layer.provide(layerPlatformTest), |
| 118 | + ), |
| 119 | + NodeServices.layer, |
| 120 | + FetchHttpClient.layer, |
| 121 | + OpenCodeRuntime.layer.pipe( |
| 122 | + Layer.provide(OpenCodeServerLedger.layerTest), |
| 123 | + Layer.provide(layerPlatformTest), |
| 124 | + ), |
| 125 | + Layer.succeed( |
| 126 | + ProviderEventLoggers.ProviderEventLoggers, |
| 127 | + ProviderEventLoggers.NoOpProviderEventLoggers, |
| 128 | + ), |
| 129 | + ProviderLatestVersions.layer, |
| 130 | + ModelManifest.layerTest, |
| 131 | + AntigravityInstallation.AntigravityInstallation.layer.pipe( |
| 132 | + Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), |
| 133 | + Layer.provide(FetchHttpClient.layer), |
| 134 | + Layer.provide(layerPlatformTest), |
| 135 | + ), |
| 136 | + Layer.mock(CodexInstallation.CodexInstallation)({ |
| 137 | + managedDirectory: "unused-managed-installation", |
| 138 | + }), |
| 139 | + Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ |
| 140 | + endpoint: Effect.succeed("unused-private-pipe"), |
| 141 | + }), |
| 142 | + Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => () => undefined }), |
| 143 | + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { |
| 144 | + getEnvironmentId: Effect.succeed( |
| 145 | + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), |
| 146 | + ), |
| 147 | + }), |
| 148 | + ), |
| 149 | + ), |
| 150 | +); |
| 151 | +const layerBuddyServer = Layer.merge(RuntimeLayer.layer, ProjectStore.layer).pipe( |
| 152 | + Layer.provide(McpSessionRegistryTestkit.layer), |
| 153 | + Layer.provide(SqlitePersistence.layerMemory), |
| 154 | + Layer.provide(layerCheckpointStore), |
| 155 | + Layer.provide(layerServerConfig), |
| 156 | + Layer.provide(layerServerSettings), |
| 157 | + Layer.provide(layerProviderInstanceRegistry), |
| 158 | + Layer.provide(ResetCreditCoordinator.layer), |
| 159 | + Layer.provide( |
| 160 | + BackgroundPolicy.layer.pipe( |
| 161 | + Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())), |
| 162 | + Layer.provide(layerServerSettings), |
| 163 | + ), |
| 164 | + ), |
| 165 | + Layer.provide(ProviderTurnStartServiceTestkit.layer), |
| 166 | + Layer.provide(layerPlatformTest), |
| 167 | + Layer.provide(McpProviderSessions.layer), |
| 168 | +); |
| 169 | + |
| 170 | +/** The text blocks of every `session/prompt` the agent received, in order. */ |
| 171 | +function receivedPrompts(): Array<Array<string>> { |
| 172 | + if (!NodeFS.existsSync(requestLog)) return []; |
| 173 | + return NodeFS.readFileSync(requestLog, "utf8") |
| 174 | + .split("\n") |
| 175 | + .flatMap((line) => { |
| 176 | + if (line.trim() === "") return []; |
| 177 | + const request = JSON.parse(line) as { |
| 178 | + method?: string; |
| 179 | + params?: { prompt?: Array<{ type: string; text?: string }> }; |
| 180 | + }; |
| 181 | + return request.method === "session/prompt" |
| 182 | + ? [ |
| 183 | + (request.params?.prompt ?? []).flatMap((block) => |
| 184 | + block.type === "text" && block.text !== undefined ? [block.text] : [], |
| 185 | + ), |
| 186 | + ] |
| 187 | + : []; |
| 188 | + }); |
| 189 | +} |
| 190 | + |
| 191 | +/** |
| 192 | + * Send a message as the person's web client does, and run the effect worker until the message's |
| 193 | + * run settles. |
| 194 | + */ |
| 195 | +const send = Effect.fn("test.buddyPrompt.send")(function* ( |
| 196 | + threadId: ThreadId, |
| 197 | + ordinal: number, |
| 198 | + message: { readonly text: string; readonly records?: ReadonlyArray<ComposerContextRecord> }, |
| 199 | +) { |
| 200 | + const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 201 | + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; |
| 202 | + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); |
| 203 | + const settled = orchestrator |
| 204 | + .streamStoredEventsFrom({ threadId, afterSequence, eventType: "run.updated" }) |
| 205 | + .pipe( |
| 206 | + Stream.filter( |
| 207 | + (stored) => |
| 208 | + stored.event.type === "run.updated" && isTerminalRunStatus(stored.event.payload.status), |
| 209 | + ), |
| 210 | + Stream.take(1), |
| 211 | + Stream.runDrain, |
| 212 | + ); |
| 213 | + yield* orchestrator.dispatch({ |
| 214 | + type: "message.dispatch", |
| 215 | + createdBy: "user", |
| 216 | + creationSource: "web", |
| 217 | + commandId: CommandId.make(`command:buddy-prompt:${ordinal}`), |
| 218 | + threadId, |
| 219 | + messageId: MessageId.make(`message:buddy-prompt:${ordinal}`), |
| 220 | + text: message.text, |
| 221 | + ...(message.records === undefined |
| 222 | + ? {} |
| 223 | + : { context: { version: 1 as const, records: [...message.records] } }), |
| 224 | + attachments: [], |
| 225 | + modelSelection: buddyModel, |
| 226 | + dispatchMode: { type: "start_immediately" }, |
| 227 | + }); |
| 228 | + yield* Effect.raceFirst( |
| 229 | + settled, |
| 230 | + Effect.forever(worker.drain().pipe(Effect.andThen(worker.awaitWork))), |
| 231 | + ); |
| 232 | +}); |
| 233 | + |
| 234 | +describe("Buddy's T3 Code", () => { |
| 235 | + it.live("gives Hermes the person's words and the page as a Czech block, turn after turn", () => |
| 236 | + Effect.gen(function* () { |
| 237 | + const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 238 | + const projectId = ProjectId.make("project:buddy"); |
| 239 | + // Buddy's project is the Buddy layer, a folder without Git. |
| 240 | + yield* (yield* ProjectStore.ProjectStoreV2).apply({ |
| 241 | + sequence: 1, |
| 242 | + eventId: EventId.make("event:buddy-prompt:project"), |
| 243 | + aggregateKind: "project", |
| 244 | + aggregateId: projectId, |
| 245 | + occurredAt: "2026-10-22T00:00:00.000Z", |
| 246 | + commandId: null, |
| 247 | + causationEventId: null, |
| 248 | + correlationId: null, |
| 249 | + metadata: {}, |
| 250 | + type: "project.created", |
| 251 | + payload: { |
| 252 | + projectId, |
| 253 | + title: "Buddy", |
| 254 | + workspaceRoot: NodePath.dirname(requestLog), |
| 255 | + defaultModelSelection: buddyModel, |
| 256 | + scripts: [], |
| 257 | + createdAt: "2026-10-22T00:00:00.000Z", |
| 258 | + updatedAt: "2026-10-22T00:00:00.000Z", |
| 259 | + }, |
| 260 | + }); |
| 261 | + const threadId = ThreadId.make("thread:buddy-prompt"); |
| 262 | + yield* orchestrator.dispatch({ |
| 263 | + type: "thread.create", |
| 264 | + createdBy: "user", |
| 265 | + creationSource: "web", |
| 266 | + commandId: CommandId.make("command:buddy-prompt:create"), |
| 267 | + threadId, |
| 268 | + projectId, |
| 269 | + title: "Buddy", |
| 270 | + modelSelection: buddyModel, |
| 271 | + runtimeMode: "full-access", |
| 272 | + interactionMode: "default", |
| 273 | + branch: null, |
| 274 | + worktreePath: null, |
| 275 | + }); |
| 276 | + const record = { |
| 277 | + version: 1, |
| 278 | + kind: "lazurio-page", |
| 279 | + contextId: "lazurio-page-1", |
| 280 | + label: "Kontext stránky", |
| 281 | + payload: page, |
| 282 | + } as unknown as ComposerContextRecord; |
| 283 | + yield* send(threadId, 1, { |
| 284 | + text: "Co tu vidíte? [Kontext stránky](t3-context://v1/lazurio-page/lazurio-page-1)", |
| 285 | + records: [record], |
| 286 | + }); |
| 287 | + yield* send(threadId, 2, { text: "A teď bez stránky." }); |
| 288 | + |
| 289 | + assert.deepStrictEqual(receivedPrompts(), [ |
| 290 | + [`Co tu vidíte?\n\n${lazurioPageContextBlock(page)}`], |
| 291 | + ["A teď bez stránky."], |
| 292 | + ]); |
| 293 | + }).pipe( |
| 294 | + Effect.provide(layerBuddyServer), |
| 295 | + Effect.scoped, |
| 296 | + Effect.provideService(HostProcess.Environment, { |
| 297 | + ...process.env, |
| 298 | + T3CODE_LAZURIO_BUDDY: "1", |
| 299 | + T3_ACP_REQUEST_LOG_PATH: requestLog, |
| 300 | + }), |
| 301 | + ), |
| 302 | + ); |
| 303 | +}); |
0 commit comments