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),