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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 6 additions & 42 deletions packages/core/execution/src/tool-invoker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Fiber, Schema } from "effect";
import * as ts from "typescript";

import {
ElicitationResponse,
Expand All @@ -10,7 +9,7 @@ import {
definePlugin,
tool,
} from "@executor-js/sdk";
import { makeTestConfig } from "@executor-js/sdk/testing";
import { makeTestConfig, typeCheckOutputTypeScript } from "@executor-js/sdk/testing";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { createExecutionEngine } from "./engine";
import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker";
Expand Down Expand Up @@ -44,48 +43,13 @@ const typeCheckDescribedInvocation = (
described: DescribedToolContract,
runtimeResult: unknown,
consumerSource: string,
): readonly string[] => {
const fileName = "described-tool-contract.ts";
const source = [
...Object.entries(described.typeScriptDefinitions).map(([name, definition]) => {
return `type ${name} = ${definition};`;
}),
`type ToolOutput = ${described.outputTypeScript};`,
`const invokedResult: ToolOutput = ${JSON.stringify(runtimeResult)};`,
): readonly string[] =>
typeCheckOutputTypeScript(described, runtimeResult, {
consumerSource,
].join("\n");

const options: ts.CompilerOptions = {
module: ts.ModuleKind.ESNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ES2022,
};
const host = ts.createCompilerHost(options);
const originalGetSourceFile = host.getSourceFile.bind(host);
const originalReadFile = host.readFile.bind(host);
const originalFileExists = host.fileExists.bind(host);

host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => {
if (candidate === fileName) {
return ts.createSourceFile(candidate, source, languageVersion, true);
}
return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile);
};
host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate));
host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate);

const program = ts.createProgram([fileName], options, host);
return ts.getPreEmitDiagnostics(program).map((diagnostic) => {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
if (!diagnostic.file || diagnostic.start === undefined) {
return message;
}
const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`;
fileName: "described-tool-contract.ts",
typeName: "ToolOutput",
valueName: "invokedResult",
});
};

// ---------------------------------------------------------------------------
// Test plugins — each one declares a namespace as a static source with N
Expand Down
5 changes: 5 additions & 0 deletions packages/core/sdk/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export {
type OAuthTestServerShape,
} from "./testing/oauth-test-server";
export { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
export {
typeCheckOutputTypeScript,
type OutputTypeScriptContract,
type TypeCheckOutputTypeScriptOptions,
} from "./testing/tool-output-contract";

export class TestHttpServerAddressError extends Data.TaggedError("TestHttpServerAddressError")<{
readonly address: unknown;
Expand Down
52 changes: 52 additions & 0 deletions packages/core/sdk/src/testing/tool-output-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "@effect/vitest";

import { typeCheckOutputTypeScript } from "./tool-output-contract";

describe("typeCheckOutputTypeScript", () => {
it("accepts runtime output that matches the described TypeScript contract", () => {
const diagnostics = typeCheckOutputTypeScript(
{
outputTypeScript: "{ ok: true; data: ResultData }",
typeScriptDefinitions: {
Payload: "{ answer: string }",
ResultData:
'{ content: readonly { type: "text"; text: string }[]; structuredContent: Payload }',
},
},
{
ok: true,
data: {
content: [{ type: "text", text: "done" }],
structuredContent: { answer: "done" },
},
},
{
consumerSource:
"const answer: string = invokedOutput.data.structuredContent.answer; answer;",
},
);

expect(diagnostics).toEqual([]);
});

it("reports when the described contract omits the runtime output wrapper", () => {
const diagnostics = typeCheckOutputTypeScript(
{
outputTypeScript: "{ ok: true; data: { answer: string } }",
},
{
ok: true,
data: {
content: [{ type: "text", text: "done" }],
structuredContent: { answer: "done" },
},
},
);

expect(diagnostics.join("\n")).toContain("answer");
});

it("reports missing output TypeScript contracts", () => {
expect(typeCheckOutputTypeScript({}, { ok: true })).toEqual(["missing outputTypeScript"]);
});
});
66 changes: 66 additions & 0 deletions packages/core/sdk/src/testing/tool-output-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as ts from "typescript";

export type OutputTypeScriptContract = {
readonly outputTypeScript?: string;
readonly typeScriptDefinitions?: Record<string, string>;
};

export type TypeCheckOutputTypeScriptOptions = {
readonly consumerSource?: string;
readonly fileName?: string;
readonly typeName?: string;
readonly valueName?: string;
};

export const typeCheckOutputTypeScript = (
contract: OutputTypeScriptContract | null | undefined,
runtimeOutput: unknown,
options: TypeCheckOutputTypeScriptOptions = {},
): readonly string[] => {
if (!contract?.outputTypeScript) {
return ["missing outputTypeScript"];
}

const fileName = options.fileName ?? "tool-output-contract.ts";
const typeName = options.typeName ?? "ToolOutput";
const valueName = options.valueName ?? "invokedOutput";
const source = [
...Object.entries(contract.typeScriptDefinitions ?? {}).map(
([name, definition]) => `type ${name} = ${definition};`,
),
`type ${typeName} = ${contract.outputTypeScript};`,
`const ${valueName}: ${typeName} = ${JSON.stringify(runtimeOutput)};`,
options.consumerSource ?? `${valueName};`,
].join("\n");

const compilerOptions: ts.CompilerOptions = {
module: ts.ModuleKind.ESNext,
noEmit: true,
skipLibCheck: true,
strict: true,
target: ts.ScriptTarget.ES2022,
};
const host = ts.createCompilerHost(compilerOptions);
const originalGetSourceFile = host.getSourceFile.bind(host);
const originalReadFile = host.readFile.bind(host);
const originalFileExists = host.fileExists.bind(host);

host.getSourceFile = (candidate, languageVersion, onError, shouldCreateNewSourceFile) => {
if (candidate === fileName) {
return ts.createSourceFile(candidate, source, languageVersion, true);
}
return originalGetSourceFile(candidate, languageVersion, onError, shouldCreateNewSourceFile);
};
host.readFile = (candidate) => (candidate === fileName ? source : originalReadFile(candidate));
host.fileExists = (candidate) => candidate === fileName || originalFileExists(candidate);

const program = ts.createProgram([fileName], compilerOptions, host);
return ts.getPreEmitDiagnostics(program).map((diagnostic) => {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
if (!diagnostic.file || diagnostic.start === undefined) {
return message;
}
const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
return `${diagnostic.file.fileName}:${position.line + 1}:${position.character + 1} ${message}`;
});
};
6 changes: 3 additions & 3 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"@executor-js/config": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"effect": "catalog:"
"effect": "catalog:",
"zod": "^4.3.6"
},
"devDependencies": {
"@effect/atom-react": "catalog:",
Expand All @@ -78,8 +79,7 @@
"bun-types": "catalog:",
"react": "catalog:",
"tsup": "catalog:",
"vitest": "catalog:",
"zod": "^4.3.6"
"vitest": "catalog:"
},
"peerDependencies": {
"@effect/atom-react": "catalog:",
Expand Down
Loading
Loading