test(omo-codex): batch 50 (4 files)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { argv, execPath, stderr } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runPostToolUseHookCli } from "./codex-hook.js";
|
||||
|
||||
const PACKAGE_LSP_MCP_CLI = "../../../../../lsp-tools-mcp/dist/cli.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command = "mcp", subcommand = ""] = argv.slice(2);
|
||||
|
||||
if (command === "hook" && subcommand === "post-tool-use") {
|
||||
await runPostToolUseHookCli();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "mcp") {
|
||||
await runPackageLspMcpCli();
|
||||
return;
|
||||
}
|
||||
|
||||
stderr.write("Usage: omo-lsp [mcp | hook post-tool-use]\n");
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function runPackageLspMcpCli(): Promise<void> {
|
||||
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), PACKAGE_LSP_MCP_CLI);
|
||||
const child = spawn(execPath, [cliPath, "mcp"], { stdio: "inherit" });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code !== null && code !== 0) process.exitCode = code;
|
||||
if (code === null && signal !== null) process.exitCode = 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stdin as processStdin } from "node:process";
|
||||
|
||||
import { disposeDefaultLspManager } from "@code-yeongyu/lsp-tools-mcp/dist/lsp/manager.js";
|
||||
import { executeLspDiagnostics } from "@code-yeongyu/lsp-tools-mcp/dist/tools.js";
|
||||
|
||||
export type DiagnosticsRunner = (filePath: string) => Promise<string>;
|
||||
|
||||
export interface CodexPostToolUseInput {
|
||||
tool_name?: unknown;
|
||||
tool_input?: unknown;
|
||||
tool_response?: unknown;
|
||||
transcript_path?: unknown;
|
||||
}
|
||||
|
||||
interface DiagnosticBlock {
|
||||
filePath: string;
|
||||
diagnostics: string;
|
||||
}
|
||||
|
||||
interface PostToolUseHookOutput {
|
||||
decision: "block";
|
||||
reason: string;
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PostToolUse";
|
||||
additionalContext: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MUTATION_TOOL_NAMES = new Set(["apply_patch", "write", "edit", "multiedit", "multi_edit"]);
|
||||
const CLEAN_DIAGNOSTICS_TEXT = "No diagnostics found";
|
||||
const UNSUPPORTED_EXTENSION_TEXT = "No LSP server configured for extension:";
|
||||
const DIAGNOSTIC_START_PATTERN = /(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/g;
|
||||
const DIAGNOSTIC_CHUNK_PATTERN = /^(?:error|warning|information|hint)\[[^\]\r\n]+\] \(\d+\) at \d+:\d+:/;
|
||||
const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000;
|
||||
const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200;
|
||||
const MAX_CONCURRENT_DIAGNOSTICS = 4;
|
||||
const CONTEXT_PRESSURE_MARKERS = [
|
||||
"context compacted",
|
||||
"context_length_exceeded",
|
||||
"skill descriptions were shortened",
|
||||
"context_too_large",
|
||||
"codex ran out of room in the model's context window",
|
||||
"your input exceeds the context window",
|
||||
"long threads and multiple compactions",
|
||||
] as const;
|
||||
|
||||
export async function runLspDiagnosticsText(filePath: string): Promise<string> {
|
||||
const result = await executeLspDiagnostics({ filePath, severity: "error" });
|
||||
return result.content.map((block) => block.text).join("\n");
|
||||
}
|
||||
|
||||
export async function runLspPostToolUseHook(
|
||||
input: CodexPostToolUseInput,
|
||||
runDiagnostics: DiagnosticsRunner = runLspDiagnosticsText,
|
||||
): Promise<string> {
|
||||
const filePaths = extractMutatedFilePaths(input);
|
||||
if (filePaths.length === 0) return "";
|
||||
|
||||
const blocks: DiagnosticBlock[] = [];
|
||||
for (const { filePath, diagnostics } of await collectDiagnostics(filePaths, runDiagnostics)) {
|
||||
if (isCleanDiagnostics(diagnostics)) continue;
|
||||
blocks.push({ filePath, diagnostics });
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return "";
|
||||
|
||||
const rawReason = blocks.map(formatDiagnosticBlock).join("\n\n");
|
||||
const reason = limitHookText(rawReason, hookFeedbackLimit(input.transcript_path));
|
||||
const output: PostToolUseHookOutput = {
|
||||
decision: "block",
|
||||
reason,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PostToolUse",
|
||||
additionalContext: reason,
|
||||
},
|
||||
};
|
||||
return `${JSON.stringify(output)}\n`;
|
||||
}
|
||||
|
||||
async function collectDiagnostics(
|
||||
filePaths: readonly string[],
|
||||
runDiagnostics: DiagnosticsRunner,
|
||||
): Promise<DiagnosticBlock[]> {
|
||||
const results: DiagnosticBlock[] = [];
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(MAX_CONCURRENT_DIAGNOSTICS, filePaths.length);
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
const filePath = filePaths[index];
|
||||
if (filePath === undefined) return;
|
||||
results[index] = { filePath, diagnostics: (await runDiagnostics(filePath)).trim() };
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatDiagnosticBlock({ filePath, diagnostics }: DiagnosticBlock): string {
|
||||
return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`;
|
||||
}
|
||||
|
||||
function formatDiagnosticsForDisplay(diagnostics: string): string {
|
||||
const chunks = splitDiagnosticChunks(diagnostics);
|
||||
if (!chunks.some(isDiagnosticChunk)) return chunks.join("\n").trim();
|
||||
return chunks.map(formatDiagnosticChunk).join("\n");
|
||||
}
|
||||
|
||||
function splitDiagnosticChunks(diagnostics: string): string[] {
|
||||
const normalized = diagnostics.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
||||
if (normalized.length === 0) return [];
|
||||
|
||||
const matches = Array.from(normalized.matchAll(DIAGNOSTIC_START_PATTERN));
|
||||
const firstMatch = matches[0];
|
||||
if (firstMatch?.index === undefined) return [normalized];
|
||||
|
||||
const chunks: string[] = [];
|
||||
const leadingText = normalized.slice(0, firstMatch.index).trim();
|
||||
if (leadingText.length > 0) chunks.push(leadingText);
|
||||
|
||||
for (const [index, match] of matches.entries()) {
|
||||
if (match.index === undefined) continue;
|
||||
const nextMatch = matches[index + 1];
|
||||
const end = nextMatch?.index ?? normalized.length;
|
||||
const chunk = normalized.slice(match.index, end).trim();
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function formatDiagnosticChunk(chunk: string): string {
|
||||
const lines = chunk.split("\n");
|
||||
const firstLine = lines[0];
|
||||
if (firstLine === undefined) return "";
|
||||
if (!isDiagnosticChunk(firstLine)) return chunk;
|
||||
const followingLines = lines.slice(1).map((line) => ` ${line}`);
|
||||
return [`- ${firstLine}`, ...followingLines].join("\n");
|
||||
}
|
||||
|
||||
function isDiagnosticChunk(chunk: string): boolean {
|
||||
return DIAGNOSTIC_CHUNK_PATTERN.test(chunk);
|
||||
}
|
||||
|
||||
function hookFeedbackLimit(transcriptPath: unknown): number {
|
||||
return isContextPressureTranscript(transcriptPath)
|
||||
? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS
|
||||
: DEFAULT_MAX_HOOK_FEEDBACK_CHARS;
|
||||
}
|
||||
|
||||
function isContextPressureTranscript(transcriptPath: unknown): boolean {
|
||||
if (typeof transcriptPath !== "string") return false;
|
||||
try {
|
||||
return hasContextPressureMarker(readFileSync(transcriptPath, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hasContextPressureMarker(text: string): boolean {
|
||||
const normalizedText = text.toLowerCase();
|
||||
return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker));
|
||||
}
|
||||
|
||||
function limitHookText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`;
|
||||
if (marker.length >= maxChars) return marker.slice(0, maxChars);
|
||||
const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, "");
|
||||
return `${head}${marker}`;
|
||||
}
|
||||
|
||||
export function extractMutatedFilePaths(input: CodexPostToolUseInput): string[] {
|
||||
if (!isMutationTool(input.tool_name)) return [];
|
||||
if (isFailedToolResponse(input.tool_response)) return [];
|
||||
|
||||
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
|
||||
const paths = new Set<string>();
|
||||
addStringValue(paths, toolInput["path"]);
|
||||
addStringValue(paths, toolInput["filePath"]);
|
||||
addStringValue(paths, toolInput["file_path"]);
|
||||
addStringArray(paths, toolInput["paths"]);
|
||||
addStringArray(paths, toolInput["filePaths"]);
|
||||
addStringArray(paths, toolInput["file_paths"]);
|
||||
addPatchPayloads(paths, toolInput);
|
||||
addPatchFiles(paths, toolInput["files"]);
|
||||
addPatchFiles(paths, toolInput["changes"]);
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
export async function runPostToolUseHookCli(stdin: NodeJS.ReadStream = processStdin): Promise<void> {
|
||||
try {
|
||||
const raw = await readStdin(stdin);
|
||||
if (!raw.trim()) return;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const input = isRecord(parsed) ? parsed : {};
|
||||
const output = await runLspPostToolUseHook(input);
|
||||
if (output) process.stdout.write(output);
|
||||
} finally {
|
||||
await disposeDefaultLspManager();
|
||||
}
|
||||
}
|
||||
|
||||
function isMutationTool(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
return MUTATION_TOOL_NAMES.has(value.toLowerCase());
|
||||
}
|
||||
|
||||
function isCleanDiagnostics(diagnostics: string): boolean {
|
||||
return (
|
||||
diagnostics.length === 0 ||
|
||||
diagnostics === CLEAN_DIAGNOSTICS_TEXT ||
|
||||
diagnostics.startsWith(UNSUPPORTED_EXTENSION_TEXT)
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolResponse(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
|
||||
);
|
||||
}
|
||||
|
||||
function addStringValue(paths: Set<string>, value: unknown): void {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
paths.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
function addStringArray(paths: Set<string>, value: unknown): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
addStringValue(paths, item);
|
||||
}
|
||||
}
|
||||
|
||||
function addPatchPayloads(paths: Set<string>, input: Record<string, unknown>): void {
|
||||
addPatchInput(paths, input["input"]);
|
||||
addPatchInput(paths, input["patch"]);
|
||||
addPatchInput(paths, input["command"]);
|
||||
}
|
||||
|
||||
function addPatchInput(paths: Set<string>, value: unknown): void {
|
||||
if (typeof value !== "string") return;
|
||||
for (const line of value.split("\n")) {
|
||||
const path = extractPatchHeaderPath(line);
|
||||
if (path !== undefined) paths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
function extractPatchHeaderPath(line: string): string | undefined {
|
||||
const prefixes = ["*** Add File: ", "*** Update File: ", "*** Move to: "] as const;
|
||||
for (const prefix of prefixes) {
|
||||
if (line.startsWith(prefix)) return line.slice(prefix.length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function addPatchFiles(paths: Set<string>, value: unknown): void {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
addStringValue(paths, item["path"]);
|
||||
addStringValue(paths, item["filePath"]);
|
||||
addStringValue(paths, item["file_path"]);
|
||||
addStringValue(paths, item["movePath"]);
|
||||
addStringValue(paths, item["move_path"]);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readStdin(stdin: NodeJS.ReadStream): Promise<string> {
|
||||
stdin.setEncoding("utf8");
|
||||
let raw = "";
|
||||
for await (const chunk of stdin) {
|
||||
raw += chunk;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMutatedFilePaths, runLspPostToolUseHook } from "../src/codex-hook.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("codex PostToolUse hook", () => {
|
||||
it("extracts files from Codex apply_patch command payloads", () => {
|
||||
const paths = extractMutatedFilePaths({
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new.ts",
|
||||
"+export const value = 1;",
|
||||
"*** Update File: src/existing.ts",
|
||||
"@@",
|
||||
"-export const old = true;",
|
||||
"+export const old = false;",
|
||||
"*** End Patch",
|
||||
].join("\n"),
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
});
|
||||
|
||||
expect(paths).toEqual(["src/new.ts", "src/existing.ts"]);
|
||||
});
|
||||
|
||||
it("extracts files from edit-style tool input aliases", () => {
|
||||
const paths = extractMutatedFilePaths({
|
||||
tool_name: "Edit",
|
||||
tool_input: { file_path: "src/edit.ts" },
|
||||
tool_response: { ok: true },
|
||||
});
|
||||
|
||||
expect(paths).toEqual(["src/edit.ts"]);
|
||||
});
|
||||
|
||||
it("#given post-edit diagnostics contain one error #when the hook blocks #then it keeps the blocked output shape", async () => {
|
||||
// given
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
},
|
||||
async (filePath) => {
|
||||
expect(filePath).toBe("src/broken.ts");
|
||||
return "error[typescript] (2304) at 1:1: Cannot find name 'missing'.";
|
||||
},
|
||||
);
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
|
||||
// then
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
decision: "block",
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PostToolUse",
|
||||
additionalContext:
|
||||
"LSP diagnostics after editing src/broken.ts:\n\n" +
|
||||
"- error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
|
||||
},
|
||||
reason:
|
||||
"LSP diagnostics after editing src/broken.ts:\n\n" +
|
||||
"- error[typescript] (2304) at 1:1: Cannot find name 'missing'.",
|
||||
});
|
||||
expect(parsed).toHaveProperty("decision", "block");
|
||||
});
|
||||
|
||||
it("#given adjacent TypeScript diagnostics #when the hook blocks #then it renders each diagnostic on its own bullet line", async () => {
|
||||
// given
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
},
|
||||
async () =>
|
||||
"error[typescript] (2307) at 5:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.error[typescript] (2307) at 6:49: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.error[typescript] (2307) at 10:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
|
||||
);
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
|
||||
|
||||
// then
|
||||
expect(parsed.reason).toBe(
|
||||
[
|
||||
"LSP diagnostics after editing src/broken.ts:",
|
||||
"",
|
||||
"- error[typescript] (2307) at 5:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
|
||||
"- error[typescript] (2307) at 6:49: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
|
||||
"- error[typescript] (2307) at 10:7: Cannot find module 'openclaw/plugin-sdk/config-runtime' or its corresponding type declarations.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
|
||||
});
|
||||
|
||||
it("#given plain non-diagnostic feedback #when the hook blocks #then it preserves the text after the readable header", async () => {
|
||||
// given
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "write",
|
||||
tool_input: { path: "src/broken.ts" },
|
||||
tool_response: { ok: true },
|
||||
},
|
||||
async () => "language server failed before diagnostics could be collected",
|
||||
);
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
|
||||
|
||||
// then
|
||||
expect(parsed.reason).toBe(
|
||||
"LSP diagnostics after editing src/broken.ts:\n\nlanguage server failed before diagnostics could be collected",
|
||||
);
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
|
||||
});
|
||||
|
||||
it("#given plain non-diagnostic feedback with CRLF and bare CR #when the hook blocks #then it normalizes line endings", async () => {
|
||||
// given
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "write",
|
||||
tool_input: { path: "src/broken.ts" },
|
||||
tool_response: { ok: true },
|
||||
},
|
||||
async () => "\r\nlanguage server failed\r\n retry detail\rbefore diagnostics could be collected\r\n",
|
||||
);
|
||||
|
||||
// when
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
|
||||
|
||||
// then
|
||||
expect(parsed.reason).toBe(
|
||||
"LSP diagnostics after editing src/broken.ts:\n\nlanguage server failed\n retry detail\nbefore diagnostics could be collected",
|
||||
);
|
||||
expect(parsed.reason).not.toContain("\r");
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
|
||||
});
|
||||
|
||||
it("#given multiple edited files #when only one file has diagnostics #then it injects only files with diagnostics", async () => {
|
||||
// given
|
||||
const checkedFilePaths: string[] = [];
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "MultiEdit",
|
||||
tool_input: {
|
||||
file_paths: ["src/clean.ts", "README.md", "src/broken.ts", "src/broken.ts"],
|
||||
},
|
||||
tool_response: { ok: true },
|
||||
},
|
||||
async (filePath) => {
|
||||
checkedFilePaths.push(filePath);
|
||||
if (filePath === "src/broken.ts") {
|
||||
return "error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";
|
||||
}
|
||||
if (filePath === "README.md") {
|
||||
return "No LSP server configured for extension: .md";
|
||||
}
|
||||
return "No diagnostics found";
|
||||
},
|
||||
);
|
||||
|
||||
// when
|
||||
const expectedDiagnostics =
|
||||
"LSP diagnostics after editing src/broken.ts:\n\n" +
|
||||
"- error[typescript] (2322) at 1:7: Type 'number' is not assignable to type 'string'.";
|
||||
|
||||
// then
|
||||
expect(checkedFilePaths).toEqual(["src/clean.ts", "README.md", "src/broken.ts"]);
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
decision: "block",
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PostToolUse",
|
||||
additionalContext: expectedDiagnostics,
|
||||
},
|
||||
reason: expectedDiagnostics,
|
||||
});
|
||||
});
|
||||
|
||||
it("#given multiple edited files #when diagnostics resolve out of order #then starts bounded concurrent diagnostics and preserves output order", async () => {
|
||||
// given
|
||||
const calls: string[] = [];
|
||||
const resolvers = new Map<string, (value: string) => void>();
|
||||
const outputPromise = runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "MultiEdit",
|
||||
tool_input: { file_paths: ["src/a.ts", "src/b.ts"] },
|
||||
tool_response: { ok: true },
|
||||
},
|
||||
(filePath) =>
|
||||
new Promise<string>((resolve) => {
|
||||
calls.push(filePath);
|
||||
resolvers.set(filePath, resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
// when
|
||||
const startedBeforeRelease = [...calls];
|
||||
const resolveB = resolvers.get("src/b.ts");
|
||||
const resolveA = resolvers.get("src/a.ts");
|
||||
if (resolveB === undefined || resolveA === undefined) throw new TypeError("Expected both diagnostics to start");
|
||||
resolveB("error[typescript] (1000) at 1:1: src/b.ts failed.");
|
||||
await Promise.resolve();
|
||||
resolveA("error[typescript] (1000) at 1:1: src/a.ts failed.");
|
||||
const parsed: unknown = JSON.parse(await outputPromise);
|
||||
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
|
||||
|
||||
// then
|
||||
expect(startedBeforeRelease).toEqual(["src/a.ts", "src/b.ts"]);
|
||||
expect(parsed.reason.indexOf("src/a.ts")).toBeLessThan(parsed.reason.indexOf("src/b.ts"));
|
||||
});
|
||||
|
||||
it("#given six edited files #when diagnostics run #then at most four are active concurrently", async () => {
|
||||
// given
|
||||
const calls: string[] = [];
|
||||
const resolvers = new Map<string, (value: string) => void>();
|
||||
const filePaths = ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts", "src/f.ts"];
|
||||
const outputPromise = runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "MultiEdit",
|
||||
tool_input: { file_paths: filePaths },
|
||||
tool_response: { ok: true },
|
||||
},
|
||||
(filePath) =>
|
||||
new Promise<string>((resolve) => {
|
||||
calls.push(filePath);
|
||||
resolvers.set(filePath, resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
// when
|
||||
const initialCalls = [...calls];
|
||||
for (const filePath of filePaths) {
|
||||
resolvers.get(filePath)?.("No diagnostics found");
|
||||
await Promise.resolve();
|
||||
}
|
||||
await outputPromise;
|
||||
|
||||
// then
|
||||
expect(initialCalls).toEqual(["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"]);
|
||||
});
|
||||
|
||||
it("does not run diagnostics for failed mutation tool responses", async () => {
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: "*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+missing();\n*** End Patch\n",
|
||||
},
|
||||
tool_response: { isError: true },
|
||||
},
|
||||
async () => {
|
||||
throw new Error("diagnostics should not run after failed mutations");
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("is silent for clean diagnostics and unsupported extensions", async () => {
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command: "*** Begin Patch\n*** Update File: README.md\n@@\n+hello\n*** End Patch\n",
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
},
|
||||
async () => "No LSP server configured for extension: .md",
|
||||
);
|
||||
|
||||
expect(output).toBe("");
|
||||
});
|
||||
|
||||
it("#given Codex canonical context-window transcript and large diagnostics #when the hook blocks #then it caps injected feedback", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "codex-lsp-context-pressure-"));
|
||||
tempDirs.push(root);
|
||||
const transcriptPath = path.join(root, "transcript.jsonl");
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
[
|
||||
"context_length_exceeded",
|
||||
"Codex ran out of room in the model's context window. Start a new thread before retrying.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const largeDiagnostics = [
|
||||
"error[typescript] (2322) at 1:1: Type 'number' is not assignable to type 'string'.",
|
||||
"x".repeat(10_000),
|
||||
].join("\n");
|
||||
|
||||
const output = await runLspPostToolUseHook(
|
||||
{
|
||||
tool_name: "apply_patch",
|
||||
tool_input: {
|
||||
command:
|
||||
"*** Begin Patch\n*** Update File: src/broken.ts\n@@\n+const value: string = 1;\n*** End Patch\n",
|
||||
},
|
||||
tool_response: "Success. Updated files.",
|
||||
transcript_path: transcriptPath,
|
||||
},
|
||||
async () => largeDiagnostics,
|
||||
);
|
||||
|
||||
const parsed: unknown = JSON.parse(output);
|
||||
if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output");
|
||||
|
||||
expect(parsed.reason.length).toBeLessThanOrEqual(1200);
|
||||
expect(parsed.reason).toContain("LSP diagnostics after editing src/broken.ts");
|
||||
expect(parsed.reason).toContain("[Truncated hook output");
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason);
|
||||
});
|
||||
});
|
||||
|
||||
interface PostToolUseHookOutput {
|
||||
readonly decision: "block";
|
||||
readonly reason: string;
|
||||
readonly hookSpecificOutput: {
|
||||
readonly hookEventName: "PostToolUse";
|
||||
readonly additionalContext: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isPostToolUseHookOutput(value: unknown): value is PostToolUseHookOutput {
|
||||
if (!isRecord(value)) return false;
|
||||
const hookSpecificOutput = value["hookSpecificOutput"];
|
||||
return (
|
||||
value["decision"] === "block" &&
|
||||
typeof value["reason"] === "string" &&
|
||||
isRecord(hookSpecificOutput) &&
|
||||
hookSpecificOutput["hookEventName"] === "PostToolUse" &&
|
||||
typeof hookSpecificOutput["additionalContext"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
readonly version: string;
|
||||
readonly type: string;
|
||||
readonly packageManager: string;
|
||||
readonly bin: Record<string, string>;
|
||||
readonly dependencies: Record<string, string>;
|
||||
readonly scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
type HookCommand = {
|
||||
readonly command: string;
|
||||
};
|
||||
|
||||
type HookEntry = {
|
||||
readonly hooks: readonly HookCommand[];
|
||||
};
|
||||
|
||||
type HooksJson = {
|
||||
readonly hooks: Record<string, readonly HookEntry[]>;
|
||||
};
|
||||
|
||||
type McpServer = {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
};
|
||||
|
||||
type McpJson = {
|
||||
readonly mcpServers: Record<string, McpServer>;
|
||||
};
|
||||
|
||||
function readPackageJson(path: string): PackageJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readHooksJson(path: string): HooksJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readMcpJson(path: string): McpJson {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!isMcpJson(parsed)) throw new TypeError(`Invalid MCP metadata: ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe("plugin package metadata", () => {
|
||||
it("#given packaged component files #when validating entrypoints #then hook command stays local and MCP command references the package", () => {
|
||||
// given
|
||||
const packageJson = readPackageJson("package.json");
|
||||
const hooksJson = readHooksJson("hooks/hooks.json");
|
||||
const mcpJson = readMcpJson(".mcp.json");
|
||||
const cliSource = readFileSync("src/cli.ts", "utf8");
|
||||
const sourceFiles = readdirSync("src");
|
||||
|
||||
// when
|
||||
const command = hooksJson.hooks["PostToolUse"]?.[0]?.hooks[0]?.command;
|
||||
const lspServer = mcpJson.mcpServers["lsp"];
|
||||
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
|
||||
|
||||
// then
|
||||
expect(packageJson.type).toBe("module");
|
||||
expect(packageJson.packageManager).toBe("npm@11.12.1");
|
||||
expect(packageJson.dependencies).toEqual({
|
||||
"@code-yeongyu/lsp-tools-mcp": "file:../../../../lsp-tools-mcp",
|
||||
});
|
||||
expect(packageJson.bin["omo-lsp"]).toBe("./dist/cli.js");
|
||||
expect(packageJson.bin["codex-lsp"]).toBeUndefined();
|
||||
expect(packageJson.scripts["build"]).toBe("node scripts/clean-dist.mjs && tsc -p tsconfig.build.json");
|
||||
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
expect(cliSource).toContain("Usage: omo-lsp [mcp | hook post-tool-use]");
|
||||
expect(command).toBe(`node "${pluginRoot}/dist/cli.js" hook post-tool-use`);
|
||||
expect(lspServer?.command).toBe("node");
|
||||
expect(lspServer?.args).toEqual(["../../../../lsp-tools-mcp/dist/cli.js", "mcp"]);
|
||||
expect(cliSource).not.toContain("./lazy-lsp-mcp.js");
|
||||
expect(cliSource).not.toContain("@code-yeongyu/lsp-tools-mcp");
|
||||
expect(cliSource).toContain("../../../../../lsp-tools-mcp/dist/cli.js");
|
||||
expect(sourceFiles.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("#given LSP skill guidance #when validating MCP tool instructions #then tool names are not framed as shell commands", () => {
|
||||
// given
|
||||
const skill = readFileSync("skills/lsp/SKILL.md", "utf8");
|
||||
|
||||
// when
|
||||
const mentionsToolInterface = skill.includes("through the tool interface");
|
||||
const rejectsShellExecution = skill.includes("not shell commands");
|
||||
|
||||
// then
|
||||
expect(mentionsToolInterface).toBe(true);
|
||||
expect(rejectsShellExecution).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function isPackageJson(value: unknown): value is PackageJson {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value["version"] === "string" &&
|
||||
value["type"] === "module" &&
|
||||
value["packageManager"] === "npm@11.12.1" &&
|
||||
isStringRecord(value["bin"]) &&
|
||||
isStringRecord(value["dependencies"]) &&
|
||||
isStringRecord(value["scripts"])
|
||||
);
|
||||
}
|
||||
|
||||
function isHooksJson(value: unknown): value is HooksJson {
|
||||
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
|
||||
return Object.values(value["hooks"]).every(isHookEntries);
|
||||
}
|
||||
|
||||
function isHookEntries(value: unknown): value is readonly HookEntry[] {
|
||||
return Array.isArray(value) && value.every(isHookEntry);
|
||||
}
|
||||
|
||||
function isHookEntry(value: unknown): value is HookEntry {
|
||||
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
|
||||
}
|
||||
|
||||
function isHookCommand(value: unknown): value is HookCommand {
|
||||
return isRecord(value) && typeof value["command"] === "string";
|
||||
}
|
||||
|
||||
function isMcpJson(value: unknown): value is McpJson {
|
||||
if (!isRecord(value) || !isRecord(value["mcpServers"])) return false;
|
||||
return Object.values(value["mcpServers"]).every(isMcpServer);
|
||||
}
|
||||
|
||||
function isMcpServer(value: unknown): value is McpServer {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value["command"] === "string" &&
|
||||
Array.isArray(value["args"]) &&
|
||||
value["args"].every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user