11import { describe , expect , it } from "@effect/vitest" ;
22import { Effect , Fiber , Schema } from "effect" ;
3+ import * as ts from "typescript" ;
34
45import {
56 ElicitationResponse ,
@@ -19,6 +20,10 @@ const RepoInputSchema = Schema.toStandardSchemaV1(
1920 Schema . toStandardJSONSchemaV1 ( Schema . Struct ( { owner : Schema . String , repo : Schema . String } ) ) ,
2021) ;
2122
23+ const RepoDetailsOutputSchema = Schema . toStandardSchemaV1 (
24+ Schema . toStandardJSONSchemaV1 ( Schema . Struct ( { defaultBranch : Schema . String } ) ) ,
25+ ) ;
26+
2227const ContactInputSchema = Schema . toStandardSchemaV1 (
2328 Schema . toStandardJSONSchemaV1 ( Schema . Struct ( { email : Schema . String } ) ) ,
2429) ;
@@ -29,6 +34,58 @@ const EmptyInputSchema = Schema.toStandardSchemaV1(
2934
3035const acceptAll = ( ) => Effect . succeed ( ElicitationResponse . make ( { action : "accept" } ) ) ;
3136
37+ type DescribedToolContract = {
38+ readonly outputTypeScript : string ;
39+ readonly typeScriptDefinitions : Record < string , string > ;
40+ } ;
41+
42+ const typeCheckDescribedInvocation = (
43+ described : DescribedToolContract ,
44+ runtimeResult : unknown ,
45+ consumerSource : string ,
46+ ) : readonly string [ ] => {
47+ const fileName = "described-tool-contract.ts" ;
48+ const source = [
49+ ...Object . entries ( described . typeScriptDefinitions ) . map ( ( [ name , definition ] ) => {
50+ return `type ${ name } = ${ definition } ;` ;
51+ } ) ,
52+ `type ToolOutput = ${ described . outputTypeScript } ;` ,
53+ `const invokedResult: ToolOutput = ${ JSON . stringify ( runtimeResult ) } ;` ,
54+ consumerSource ,
55+ ] . join ( "\n" ) ;
56+
57+ const options : ts . CompilerOptions = {
58+ module : ts . ModuleKind . ESNext ,
59+ noEmit : true ,
60+ skipLibCheck : true ,
61+ strict : true ,
62+ target : ts . ScriptTarget . ES2022 ,
63+ } ;
64+ const host = ts . createCompilerHost ( options ) ;
65+ const originalGetSourceFile = host . getSourceFile . bind ( host ) ;
66+ const originalReadFile = host . readFile . bind ( host ) ;
67+ const originalFileExists = host . fileExists . bind ( host ) ;
68+
69+ host . getSourceFile = ( candidate , languageVersion , onError , shouldCreateNewSourceFile ) => {
70+ if ( candidate === fileName ) {
71+ return ts . createSourceFile ( candidate , source , languageVersion , true ) ;
72+ }
73+ return originalGetSourceFile ( candidate , languageVersion , onError , shouldCreateNewSourceFile ) ;
74+ } ;
75+ host . readFile = ( candidate ) => ( candidate === fileName ? source : originalReadFile ( candidate ) ) ;
76+ host . fileExists = ( candidate ) => candidate === fileName || originalFileExists ( candidate ) ;
77+
78+ const program = ts . createProgram ( [ fileName ] , options , host ) ;
79+ return ts . getPreEmitDiagnostics ( program ) . map ( ( diagnostic ) => {
80+ const message = ts . flattenDiagnosticMessageText ( diagnostic . messageText , "\n" ) ;
81+ if ( ! diagnostic . file || diagnostic . start === undefined ) {
82+ return message ;
83+ }
84+ const position = diagnostic . file . getLineAndCharacterOfPosition ( diagnostic . start ) ;
85+ return `${ diagnostic . file . fileName } :${ position . line + 1 } :${ position . character + 1 } ${ message } ` ;
86+ } ) ;
87+ } ;
88+
3289// ---------------------------------------------------------------------------
3390// Test plugins — each one declares a namespace as a static source with N
3491// tools. Handlers return static data; the suite only cares about discovery
@@ -54,6 +111,7 @@ const githubPlugin = definePlugin(() => ({
54111 name : "getRepositoryDetails" ,
55112 description : "Get repository details including the default branch" ,
56113 inputSchema : RepoInputSchema ,
114+ outputSchema : RepoDetailsOutputSchema ,
57115 handler : ( ) => Effect . succeed ( { defaultBranch : "main" } ) ,
58116 } ,
59117 {
@@ -342,8 +400,102 @@ describe("tool discovery", () => {
342400 expect ( described . name ) . toBe ( "listRepositoryIssues" ) ;
343401 expect ( described . description ) . toBe ( "List issues for a repository" ) ;
344402 expect ( described . inputTypeScript ) . toBe ( "{ owner: string; repo: string; }" ) ;
345- expect ( described . outputTypeScript ) . toBeUndefined ( ) ;
346- expect ( described . typeScriptDefinitions ) . toBeUndefined ( ) ;
403+ expect ( described . outputTypeScript ) . toBe (
404+ "{ ok: true; data: unknown } | { ok: false; error: ToolError }" ,
405+ ) ;
406+ expect ( described . typeScriptDefinitions ) . toEqual ( {
407+ ToolError :
408+ "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }" ,
409+ } ) ;
410+ } ) ,
411+ ) ;
412+
413+ it . effect ( "describes a return type that accepts the sandbox invocation result" , ( ) =>
414+ Effect . gen ( function * ( ) {
415+ const executor = yield * makeSearchExecutor ( ) ;
416+ const engine = createExecutionEngine ( { executor, codeExecutor } ) ;
417+
418+ const execution = yield * engine . execute (
419+ [
420+ 'const details = await tools.describe.tool({ path: "github.getRepositoryDetails" });' ,
421+ "const result = await tools.github.getRepositoryDetails({ owner: 'executor', repo: 'executor' });" ,
422+ "return {" ,
423+ " outputTypeScript: details.outputTypeScript," ,
424+ " typeScriptDefinitions: details.typeScriptDefinitions," ,
425+ " result," ,
426+ "};" ,
427+ ] . join ( "\n" ) ,
428+ { onElicitation : acceptAll } ,
429+ ) ;
430+
431+ expect ( execution . error ) . toBeUndefined ( ) ;
432+ const observed = execution . result as DescribedToolContract & { readonly result : unknown } ;
433+ const diagnostics = typeCheckDescribedInvocation (
434+ observed ,
435+ observed . result ,
436+ [
437+ "function readDefaultBranch(result: ToolOutput): string {" ,
438+ " if (!result.ok) return result.error.message;" ,
439+ " return result.data.defaultBranch;" ,
440+ "}" ,
441+ "readDefaultBranch(invokedResult);" ,
442+ ] . join ( "\n" ) ,
443+ ) ;
444+ expect ( diagnostics ) . toEqual ( [ ] ) ;
445+ } ) ,
446+ ) ;
447+
448+ it . effect (
449+ "describes an error-as-value return type that accepts sandbox invocation failures" ,
450+ ( ) =>
451+ Effect . gen ( function * ( ) {
452+ const executor = yield * createExecutor (
453+ makeTestConfig ( { plugins : [ errorPlugin ( ) ] as const } ) ,
454+ ) ;
455+ const engine = createExecutionEngine ( { executor, codeExecutor } ) ;
456+
457+ const execution = yield * engine . execute (
458+ [
459+ 'const details = await tools.describe.tool({ path: "records.queryRows" });' ,
460+ "const result = await tools.records.queryRows({});" ,
461+ "return {" ,
462+ " outputTypeScript: details.outputTypeScript," ,
463+ " typeScriptDefinitions: details.typeScriptDefinitions," ,
464+ " result," ,
465+ "};" ,
466+ ] . join ( "\n" ) ,
467+ { onElicitation : acceptAll } ,
468+ ) ;
469+
470+ expect ( execution . error ) . toBeUndefined ( ) ;
471+ const observed = execution . result as DescribedToolContract & { readonly result : unknown } ;
472+ const diagnostics = typeCheckDescribedInvocation (
473+ observed ,
474+ observed . result ,
475+ [
476+ "function readToolResult(result: ToolOutput): unknown {" ,
477+ " if (!result.ok) return result.error.message;" ,
478+ " return result.data;" ,
479+ "}" ,
480+ "readToolResult(invokedResult);" ,
481+ ] . join ( "\n" ) ,
482+ ) ;
483+ expect ( diagnostics ) . toEqual ( [ ] ) ;
484+ } ) ,
485+ ) ;
486+
487+ it . effect ( "describes the ToolResult wrapper through the direct describe helper" , ( ) =>
488+ Effect . gen ( function * ( ) {
489+ const executor = yield * makeSearchExecutor ( ) ;
490+ const described = yield * describeTool ( executor , "github.getRepositoryDetails" ) ;
491+
492+ expect ( described . outputTypeScript ) . toBe (
493+ "{ ok: true; data: { defaultBranch: string; } } | { ok: false; error: ToolError }" ,
494+ ) ;
495+ expect ( described . typeScriptDefinitions ) . toEqual ( {
496+ ToolError :
497+ "{ code: string; message: string; status?: number; details?: unknown; retryable?: boolean }" ,
498+ } ) ;
347499 } ) ,
348500 ) ;
349501
0 commit comments