Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
EnvironmentMetadataHttpApi,
EnvironmentOrchestrationHttpApi,
type ExecutionEnvironmentDescriptor,
MessageId,
ProjectId,
ProviderInstanceId,
ThreadId,
Expand All @@ -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";
Expand Down Expand Up @@ -180,17 +182,18 @@ const withLiveProjectCliServer = <A, E, R>(
run: (origin: string) => Effect.Effect<A, E, R>,
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),
Expand Down Expand Up @@ -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<TurnStartBootstrap.TurnStartBootstrap["Service"]["dispatchTurnStart"]>[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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrchestrationCommand> = [];
const dispatchOptions: Array<DispatchOptions> = [];
const clientOptions = {
origin: { surface: "desktop", appVersion: "2.0.0" },
origin: { surface: "cli" },
} as const;

const result = yield* Effect.gen(function* () {
Expand Down
30 changes: 17 additions & 13 deletions apps/server/src/orchestration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down