Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type * as AcpSchema from "effect-acp/schema";

const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
const cwdLogPath = process.env.T3_ACP_CWD_LOG_PATH;
const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
Expand All @@ -30,6 +31,10 @@ const permissionOptionIds = {
};
const sessionId = "mock-session-1";

if (cwdLogPath) {
appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8");
}

let currentModeId = "ask";
let currentModelId = "default";
let parameterizedModelPicker = false;
Expand Down
81 changes: 81 additions & 0 deletions apps/server/scripts/codex-skills-mock-app-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node
// @effect-diagnostics nodeBuiltinImport:off
import { appendFileSync } from "node:fs";

const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH;
const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH;
const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1";

function appendLog(path: string | undefined, line: string): void {
if (path) appendFileSync(path, `${line}\n`, "utf8");
}

function respond(id: number | string, result: unknown): void {
process.stdout.write(`${JSON.stringify({ id, result })}\n`);
}

process.once("SIGTERM", () => {
appendLog(exitLogPath, "SIGTERM");
process.exit(0);
});
process.once("exit", (code) => appendLog(exitLogPath, `exit:${code}`));

let remainder = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
remainder += chunk;
const lines = remainder.split("\n");
remainder = lines.pop() ?? "";

for (const line of lines) {
if (!line.trim()) continue;
const message = JSON.parse(line) as Record<string, unknown>;
const id = message.id;
if ((typeof id !== "number" && typeof id !== "string") || typeof message.method !== "string") {
continue;
}

switch (message.method) {
case "initialize":
appendLog(cwdLogPath, process.cwd());
respond(id, {
userAgent: "t3code-codex-skills-test",
codexHome: process.cwd(),
platformFamily: "unix",
platformOs: "linux",
});
break;
case "account/read":
respond(id, {
account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
requiresOpenaiAuth: false,
});
break;
case "skills/list":
if (!hangSkillsList) {
respond(id, {
data: [
{
cwd: process.cwd(),
errors: [],
skills: [
{
name: "workspace-skill",
description: "A workspace-scoped test skill.",
shortDescription: "Workspace test skill",
path: `${process.cwd()}/.agents/skills/workspace-skill/SKILL.md`,
scope: "repo",
enabled: true,
},
],
},
],
});
}
break;
default:
respond(id, {});
}
}
});
process.stdin.on("end", () => process.exit(0));
8 changes: 7 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const homeLayout = yield* resolveCodexHomeLayout(config);
const continuationIdentity = codexContinuationIdentity(homeLayout);
Expand Down Expand Up @@ -166,7 +167,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
// in as instance rebuilds from the registry rather than in-place
// updates. Pre-provide `ChildProcessSpawner` so the check fits
// `makeManagedServerProvider.checkProvider`'s `R = never`.
const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe(
const checkProvider = checkCodexProviderStatus(
effectiveConfig,
serverConfig.cwd,
undefined,
processEnv,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/Drivers/CursorDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
Expand All @@ -130,7 +131,11 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
});
const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe(
const checkProvider = checkCursorProviderStatus(
effectiveConfig,
serverConfig.cwd,
processEnv,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/Drivers/GrokDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
Expand All @@ -112,7 +113,11 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
});
const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(
const checkProvider = checkGrokProviderStatus(
effectiveConfig,
serverConfig.cwd,
processEnv,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
Expand Down
111 changes: 109 additions & 2 deletions apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
import { assert, it } from "@effect/vitest";
import * as NodeOS from "node:os";
import { setTimeout as delay } from "node:timers/promises";

import { mapCodexModelCapabilities } from "./CodexProvider.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, expect, it } from "@effect/vitest";
import { ProviderInstanceId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Path from "effect/Path";
import * as TestClock from "effect/testing/TestClock";

import { listCodexProviderSkills, mapCodexModelCapabilities } from "./CodexProvider.ts";
import { listCodexProviderSkillsWithTimeout } from "../ProviderSkillsLister.ts";

const resolveMockAppServerPath = Effect.fn("resolveMockAppServerPath")(function* () {
const path = yield* Path.Path;
return yield* path.fromFileUrl(
new URL("../../../scripts/codex-skills-mock-app-server.ts", import.meta.url),
);
});

const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const mockAppServerPath = yield* resolveMockAppServerPath();
const directory = yield* fileSystem.makeTempDirectory({
directory: NodeOS.tmpdir(),
prefix: "codex-skills-provider-",
});
const binaryPath = path.join(directory, "codex");
const command = [process.execPath, mockAppServerPath]
.map((argument) => JSON.stringify(argument))
.join(" ");
yield* fileSystem.writeFileString(binaryPath, `#!/bin/sh\nexec ${command} "$@"\n`);
yield* fileSystem.chmod(binaryPath, 0o755);
const workspaceDirectory = yield* fileSystem.makeTempDirectory({
directory: NodeOS.tmpdir(),
prefix: "codex-skills-workspace-",
});
return {
binaryPath,
cwd: yield* fileSystem.realPath(workspaceDirectory),
cwdLogPath: path.join(directory, "cwd.log"),
exitLogPath: path.join(directory, "exit.log"),
};
});

const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: string) {
const fileSystem = yield* FileSystem.FileSystem;
for (let attempt = 0; attempt < 40; attempt += 1) {
const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => ""));
if (content.trim()) return content;
yield* Effect.promise(() => delay(50));
}
return yield* Effect.die(`Timed out waiting for file content at ${filePath}`);
});

it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
Expand Down Expand Up @@ -102,3 +156,56 @@ it("uses standard routing when the catalog has no default service tier", () => {
},
]);
});

describe("listCodexProviderSkills", () => {
it.effect("lists workspace skills from the configured cwd", () =>
Effect.gen(function* () {
const fixture = yield* makeMockAppServer();
const skills = yield* listCodexProviderSkills({
binaryPath: fixture.binaryPath,
cwd: fixture.cwd,
environment: {
...process.env,
T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath,
},
}).pipe(Effect.scoped);

expect(skills).toEqual([
{
name: "workspace-skill",
description: "A workspace-scoped test skill.",
shortDescription: "Workspace test skill",
path: `${fixture.cwd}/.agents/skills/workspace-skill/SKILL.md`,
scope: "repo",
enabled: true,
},
]);
expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("reports timeouts and terminates the app-server", () =>
Effect.gen(function* () {
const fixture = yield* makeMockAppServer();
const fiber = yield* listCodexProviderSkillsWithTimeout({
instanceId: ProviderInstanceId.make("codex"),
binaryPath: fixture.binaryPath,
cwd: fixture.cwd,
environment: {
...process.env,
T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath,
T3_CODEX_EXIT_LOG_PATH: fixture.exitLogPath,
T3_CODEX_HANG_SKILLS_LIST: "1",
},
}).pipe(Effect.forkChild);

yield* waitForFileContent(fixture.cwdLogPath);
yield* TestClock.adjust("15 seconds");
const error = yield* Fiber.join(fiber).pipe(Effect.flip);
expect(error.message).toBe(
`Timed out listing Codex skills after 15s (provider: 'codex', cwd: '${fixture.cwd}').`,
);
expect(yield* waitForFileContent(fixture.exitLogPath)).toContain("SIGTERM");
}).pipe(Effect.provide(NodeServices.layer)),
);
});
56 changes: 55 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,59 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
return models;
});

export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly cwd: string;
readonly environment: NodeJS.ProcessEnv;
}) {
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
...input.environment,
...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
};
const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], {
env: environment,
extendEnv: true,
});
const child = yield* spawner
.spawn(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
cwd: input.cwd,
env: environment,
extendEnv: true,
forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
shell: spawnCommand.shell,
}),
)
.pipe(
Effect.mapError(
(cause) =>
new CodexErrors.CodexAppServerSpawnError({
command: `${input.binaryPath} app-server`,
cause,
}),
),
);
const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
Effect.provide(clientContext),
);

yield* client.request("initialize", buildCodexInitializeParams());
yield* client.notify("initialized", undefined);
const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return [];
}

const response = yield* client.request("skills/list", {
cwds: [input.cwd],
});
return parseCodexSkillsListResponse(response, input.cwd);
});

export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
return {
clientInfo: {
Expand Down Expand Up @@ -459,6 +512,7 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]):

export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
codexSettings: CodexSettings,
cwd: string,
probe: (input: {
readonly binaryPath: string;
readonly homePath?: string;
Expand Down Expand Up @@ -500,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
const probeResult = yield* probe({
binaryPath: codexSettings.binaryPath,
homePath: codexSettings.homePath,
cwd: process.cwd(),
cwd,
customModels: codexSettings.customModels,
environment: resolvedEnvironment,
}).pipe(
Expand Down
Loading
Loading