diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 0c19504f0ff7..afb90d3ef21a 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -13,6 +13,7 @@ import { EnvironmentMetadataHttpApi, EnvironmentOrchestrationHttpApi, type ExecutionEnvironmentDescriptor, + MessageId, ProjectId, ProviderInstanceId, ThreadId, @@ -23,6 +24,7 @@ import { assert, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import { FetchHttpClient, HttpServer } from "effect/unstable/http"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; @@ -180,17 +182,18 @@ const withLiveProjectCliServer = ( run: (origin: string) => Effect.Effect, options?: { readonly conditionalProjectScriptUpdates?: boolean; + readonly dispatchTurnStart?: TurnStartBootstrap.TurnStartBootstrap["Service"]["dispatchTurnStart"]; }, ) => Effect.gen(function* () { const config = yield* makeCliTestServerConfig(baseDir); const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( Layer.provide(Layer.merge(orchestrationHttpApiLayer, serverEnvironmentHttpApiLayer)), - // Project CLI tests never dispatch bootstrap turn starts; the HTTP - // dispatch route only needs the service to exist. Layer.provide( Layer.mock(TurnStartBootstrap.TurnStartBootstrap)({ - dispatchTurnStart: () => Effect.die("turn-start bootstrap is not used in this test"), + dispatchTurnStart: + options?.dispatchTurnStart ?? + (() => Effect.die("turn-start bootstrap is not used in this test")), }), ), Layer.provide(environmentAuthenticatedAuthLayer), @@ -691,6 +694,101 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("stamps CLI origin on HTTP dispatch events and bootstrap commands", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-origin-live-test-"), + ); + const workspaceRoot = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-cli-origin-live-workspace-"), + ); + const bootstrapDispatchOptions: Array< + Parameters[1] + > = []; + + yield* withLiveProjectCliServer( + baseDir, + (origin) => + Effect.gen(function* () { + yield* runCliWithRuntime([ + "project", + "add", + workspaceRoot, + "--title", + "CLI Origin Project", + "--base-dir", + baseDir, + ]); + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const snapshot = yield* projectionSnapshotQuery.getSnapshot(); + const project = snapshot.projects.find( + (candidate) => candidate.workspaceRoot === workspaceRoot, + ); + assert.isTrue(project !== undefined); + + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const events = yield* Stream.runCollect(orchestrationEngine.readEvents(0)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const projectCreated = events.find( + (event) => + event.type === "project.created" && event.payload.workspaceRoot === workspaceRoot, + ); + assert.deepEqual(projectCreated?.metadata.origin, { surface: "cli" }); + + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + } as const; + const createdAt = "2026-09-01T00:00:00.000Z"; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + yield* withCliOrchestrationSession( + environmentAuth, + "CLI bootstrap origin test", + (token) => + dispatchLiveOrchestrationCommand(origin, token, { + type: "thread.turn.start", + commandId: CommandId.make("cmd-cli-bootstrap-origin"), + threadId: ThreadId.make("thread-cli-bootstrap-origin"), + message: { + messageId: MessageId.make("message-cli-bootstrap-origin"), + role: "user", + text: "Start working", + attachments: [], + }, + modelSelection, + titleSeed: "Start working", + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: project!.id, + title: "Start working", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + }, + createdAt, + }), + ).pipe(Effect.provide(FetchHttpClient.layer)); + + assert.deepEqual(bootstrapDispatchOptions, [{ origin: { surface: "cli" } }]); + }), + { + dispatchTurnStart: (_command, options) => + Effect.sync(() => bootstrapDispatchOptions.push(options)).pipe( + Effect.as({ sequence: 999 }), + ), + }, + ); + }), + ); + it.effect("manages project actions through a running server", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts b/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts index fdcfad856333..6f47402b7970 100644 --- a/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts +++ b/apps/server/src/orchestration/Services/TurnStartBootstrap.test.ts @@ -174,12 +174,12 @@ describe("TurnStartBootstrap", () => { }), ); - it.effect("passes client origin through setup activity and cleanup dispatches", () => + it.effect("passes CLI origin through setup activity and cleanup dispatches", () => Effect.gen(function* () { const dispatched: Array = []; const dispatchOptions: Array = []; const clientOptions = { - origin: { surface: "desktop", appVersion: "2.0.0" }, + origin: { surface: "cli" }, } as const; const result = yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 371c99b1ace4..c319d6cb2465 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -28,6 +28,7 @@ import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; import { TurnStartBootstrap } from "./Services/TurnStartBootstrap.ts"; const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); +const cliDispatchOptions = { origin: { surface: "cli" } } as const; /** * Handler body for GET /api/orchestration/threads/:threadId/messages, @@ -151,27 +152,30 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), ); - // Route bootstrap turn starts through the shared bootstrap program so - // HTTP clients (the CLI) get worktree preparation, setup script - // launch, and thread cleanup — identical to the WebSocket path. + // This mutation route is the CLI transport; web, desktop, and mobile + // dispatch over WebSocket with their connection metadata instead. + // Forward the CLI origin through the shared bootstrap program so every + // generated command carries the attribution too. if (normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap) { - return yield* turnStartBootstrap.dispatchTurnStart(normalizedCommand).pipe( - Effect.tapError(() => - cleanupFailedUploadedAttachments(args.payload, normalizedCommand), - ), - Effect.catch((cause) => - failEnvironmentInternal("orchestration_dispatch_failed", cause), - ), - ); + return yield* turnStartBootstrap + .dispatchTurnStart(normalizedCommand, cliDispatchOptions) + .pipe( + Effect.tapError(() => + cleanupFailedUploadedAttachments(args.payload, normalizedCommand), + ), + Effect.catch((cause) => + failEnvironmentInternal("orchestration_dispatch_failed", cause), + ), + ); } const dispatchEffect = normalizedCommand.type === "thread.message.speech.request" ? validateAndDispatchMessageSpeechRequest( projectionThreadMessageRepository, normalizedCommand, - orchestrationEngine.dispatch(normalizedCommand), + orchestrationEngine.dispatch(normalizedCommand, cliDispatchOptions), ) - : orchestrationEngine.dispatch(normalizedCommand); + : orchestrationEngine.dispatch(normalizedCommand, cliDispatchOptions); return yield* dispatchEffect.pipe( Effect.tapError(() => cleanupFailedUploadedAttachments(args.payload, normalizedCommand),