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
131 changes: 129 additions & 2 deletions apps/server/src/cli/thread.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,139 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { RunId, ThreadId } from "@t3tools/contracts";
import { AuthSessionId, ProjectId, RunId, ThreadId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import * as ConfigProvider from "effect/ConfigProvider";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as TestConsole from "effect/testing/TestConsole";
import { Command } from "effect/cli";
import { vi } from "vite-plus/test";

import { readLatestMessages, type ThreadTools, titleFromMessage, waitForRun } from "./thread.ts";
import * as McpBridge from "../mcp/AcpMcpStdioBridge.ts";
import {
makePersistedServerRuntimeState,
persistServerRuntimeState,
} from "../serverRuntimeState.ts";
import {
readLatestMessages,
threadCommand,
type ThreadTools,
titleFromMessage,
waitForRun,
} from "./thread.ts";

vi.mock("../auth/EnvironmentAuth.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auth/EnvironmentAuth.ts")>();
return {
...actual,
layerRuntime: Layer.mock(actual.EnvironmentAuth)({
issueMcpClientSession: () =>
Effect.map(DateTime.now, (expiresAt) => ({
sessionId: AuthSessionId.make("test-session"),
token: "synthetic-token",
expiresAt,
})),
revokeSession: () => Effect.succeed(true),
}),
};
});

const threadId = ThreadId.make("thread-1");
const runId = RunId.make("run-1");

it.effect(
"start resolves home-relative project paths while preserving ids and relative paths",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs
.makeTempDirectoryScoped({ prefix: "t3-thread-project-path-" })
.pipe(Effect.flatMap((directory) => fs.realPath(directory)));
const home = path.join(root, "home");
const cwd = path.join(root, "current");
const workspaceRoot = path.join(home, "My Project");
const nestedRoot = path.join(workspaceRoot, "nested");
const baseDir = path.join(root, "state");
yield* fs.makeDirectory(nestedRoot, { recursive: true });
yield* fs.makeDirectory(cwd);
const projects = [
{ id: ProjectId.make("current-project"), title: "Current", workspaceRoot: cwd },
{ id: ProjectId.make("home-project"), title: "Home", workspaceRoot: home },
{ id: ProjectId.make("intended-project"), title: "Intended", workspaceRoot },
{ id: ProjectId.make("nested-project"), title: "Nested", workspaceRoot: nestedRoot },
{ id: ProjectId.make("~/literal-id"), title: "Exact id", workspaceRoot: cwd },
];
yield* persistServerRuntimeState({
path: path.join(baseDir, "userdata", "server-runtime.json"),
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: 3_773,
}),
});
using _session = vi.spyOn(McpBridge, "mcpToolSession").mockReturnValue(
Effect.succeed({
call: (tool, args) => {
switch (tool) {
case "t3_project_list":
return Effect.succeed({ structuredContent: { projects, nextCursor: null } });
case "t3_thread_launch":
return Effect.succeed({
structuredContent: { threadId, projectId: args.projectId, runId: null },
});
default:
return Effect.die(`unexpected ${tool} call`);
}
},
}),
);

for (const [identifier, expectedProjectId] of [
["intended-project", "intended-project"],
["~/literal-id", "~/literal-id"],
[workspaceRoot, "intended-project"],
[path.relative(cwd, workspaceRoot), "intended-project"],
[".", "current-project"],
["~/My Project", "intended-project"],
["~", "home-project"],
["~/My Project/nested", "nested-project"],
["~/My Project/missing", "intended-project"],
] as const) {
yield* Command.runWith(threadCommand, { version: "0.0.0" })([
"start",
identifier,
"Inspect this project",
"--json",
"--base-dir",
baseDir,
]).pipe(
Effect.provideService(HostProcess.HomeDirectory, home),
Effect.provideService(HostProcess.WorkingDirectory, cwd),
);
assert.strictEqual(
(yield* TestConsole.logLines).at(-1),
JSON.stringify({ threadId, projectId: expectedProjectId, runId: null }),
identifier,
);
}
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
NodeCrypto.layer,
NetService.layer,
ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_PORT: "3773" } })),
),
),
),
);

/** Answers each tool call with the next scripted response for that tool. */
const scriptedTools = (script: Record<string, Array<unknown>>) => {
const calls: Array<{ readonly tool: string; readonly args: unknown }> = [];
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/cli/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerConfig from "../config.ts";
import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts";
import { expandHomePath } from "../os-jank.ts";
import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";

Expand Down Expand Up @@ -287,7 +288,9 @@ const resolveProject = Effect.fn("cli.thread.resolveProject")(function* (
// through a symlink (macOS /tmp), so both sides compare as real paths.
const realPath = (value: string) =>
fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value));
const target = yield* realPath(path.resolve(yield* HostProcess.WorkingDirectory, identifier));
const target = yield* realPath(
path.resolve(yield* HostProcess.WorkingDirectory, yield* expandHomePath(identifier)),
);
const roots = yield* Effect.forEach(projects, (project) =>
Effect.map(realPath(project.workspaceRoot), (root) => ({ project, root })),
);
Expand Down
Loading