const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); function loadAI(provider = 'litellm', chunks, content = 'Body [3].', backend) { const requests = []; class OpenAI { constructor() { this.chat = { completions: { create: async r => { requests.push(r); if (r.stream) return (async function*() { for (const chunk of chunks || []) yield chunk; })(); return { choices: [{ message: { content, tool_calls: [{ id: 'one', type: 'function', function: { name: 'generate_image', arguments: '{"prompt":"diagram"}' } }] }, finish_reason: 'tool_calls' }] }; } } }; } } const context = { module: { exports: {} }, console: { log() {}, error() {} }, process: { env: { AI_PROVIDER: provider, LITELLM_API_BASE: backend?.url || 'https://synthetic.invalid', OPENROUTER_API_KEY: 'synthetic', ...(provider === 'bedrock' ? { AWS_BEDROCK_REGION: 'synthetic-region' } : {}), ...(provider === 'azure' ? { AZURE_OPENAI_ENDPOINT: 'https://azure.synthetic.invalid', AZURE_OPENAI_API_KEY: 'synthetic', AZURE_DEPLOYMENT_NAME: 'test' } : {}) } }, require(name) { if (name === 'openai') return backend ? require('openai') : { OpenAI }; if (name === '@aws-sdk/client-bedrock-runtime') return { BedrockRuntimeClient: class {} }; if (name === './models') return { FALLBACK_MODEL: 'fallback', getEffectiveDefaultModel: async () => 'test', getAllowedModelIds: async () => new Set(['test']) }; if (name === '../db/database') return { getSetting: async () => 'false' }; if (name === './logger') return { apiCall() {}, error() {} }; if (name === './generationOptions') return require('../src/utils/generationOptions'); throw Error(name); } }; vm.runInNewContext(fs.readFileSync('src/utils/ai.js', 'utf8'), context); return { ai: context.module.exports, requests }; } const tools = [{ type: 'function', function: { name: 'generate_image', parameters: { type: 'object' } } }]; test('compatible call sends tools, returns tool_calls; ordinary request unchanged', async () => { const { ai, requests } = loadAI(); const result = await ai.callAI([], { model: 'test', tools }); assert.deepEqual(requests[0].tools, tools); assert.equal(result.toolCalls[0].function.name, 'generate_image'); await ai.callAI([], { model: 'test' }); assert.equal('tools' in requests[1], false); }); test('fragmented streaming tool arguments are accumulated without losing body', async () => { const chunks = [ { choices: [{ delta: { content: 'Body [3].', tool_calls: [{ index: 0, id: 'one', type: 'function', function: { name: 'generate_', arguments: '{"prompt":' } }] } }] }, { choices: [{ delta: { tool_calls: [{ index: 0, function: { name: 'image', arguments: '"diagram"}' } }] }, finish_reason: 'tool_calls' }] } ]; const { ai, requests } = loadAI('litellm', chunks); let text = ''; const result = await ai.callAIStream([], { model: 'test', tools }, t => { text += t; }); assert.deepEqual(requests[0].tools, tools); assert.equal(text, 'Body [3].'); assert.equal(result.toolCalls[0].function.arguments, '{"prompt":"diagram"}'); assert.equal(result.toolCalls[0].function.name, 'generate_image'); }); const imageTool = require('../src/utils/imageTool'); const express = require('express'); const realImages = require('../src/utils/generatedImages'); const id = '12345678-1234-1234-1234-123456789abc'; test('legacy direct provider rejects tool mode before calling any provider', async () => { const {ai,requests} = loadAI('bedrock'); await assert.rejects(ai.callAI([], {model:'test',tools}), /Tools require/); await assert.rejects(ai.callAIStream([], {model:'test',tools},()=>{}), /Tools require/); assert.equal(requests.length,0); }); test('tool validation caps invocations/fields; first-body continuation disables tools and never rewrites an existing body', async () => { const calls=[]; const continuations=[]; const opts = { owner:101,workflow:'clinical_assistant',body:{message:'diagram',idempotencyKey:'request'},imageContext:{request:'diagram',history:[]},messages:[{role:'user',content:'diagram'}],options:{model:'test'}, images:{enqueue:async(...v)=>{calls.push(v);return {jobId:id,status:'pending'};}},callAI:async(...v)=>{continuations.push(v);return {content:'First body [3].',finishReason:'stop'};} }; const call = {id:'one',type:'function',function:{name:'generate_image',arguments:'{"prompt":"diagram","layout":"portrait"}'}}; const original = 'Body [3].\n\nTrailing words with'; const result = await imageTool.dispatch({content:original,toolCalls:[call],finishReason:'tool_calls'},opts); assert.equal(result.content,original); assert.equal(continuations.length,0); assert.equal(calls.length,1); assert.equal(calls[0][0],101); assert.equal(calls[0][1],'clinical_assistant'); assert.equal(calls[0][3],'tool:request'); const first = await imageTool.dispatch({content:null,toolCalls:[call]},opts); assert.equal(first.content,'First body [3].'); assert.equal(continuations.length,1); assert.equal(continuations[0][1].toolChoice,'none'); assert.equal(continuations[0][0].at(-1).role,'tool'); for (const bad of [[call,call],[{...call,function:{name:'fetch_url',arguments:'{}'}}],[{...call,function:{name:'generate_image',arguments:'{"prompt":"x","model":"bad"}'}}],[{...call,function:{name:'generate_image',arguments:'{'}}]]) { await assert.rejects(imageTool.dispatch({content:original,toolCalls:bad},opts)); } await assert.rejects(imageTool.dispatch({content:original,toolCalls:[call]},{...opts,imageContext:undefined}),/original image request/); assert.equal(calls.length,2); }); function route(file, ai, jobs) { const mocks = { express, axios:{}, crypto:require('crypto'), multer:require('multer'), path:require('path'), '../utils/ai':ai, '../db/database':{getSetting:async key=>key.includes('model')?'test':null,all:async()=>[]}, '../middleware/auth':{authMiddleware(){},moderatorMiddleware(){}}, '../utils/crypto':{},'../utils/urlSafety':{},'../utils/policy':{requireFeature:()=>()=>{}}, '../utils/logger':{audit(){},error(){}},'../utils/redis':{},'../utils/clinicalPromptPool':{createClinicalPromptPool:()=>({})}, '../utils/clinicalPrompts':require('../src/utils/clinicalPrompts'), '../utils/clinicalConversation':require('../src/utils/clinicalConversation'), '../utils/clinicalAnswer':require('../src/utils/clinicalAnswer'), '../utils/generatedImages':realImages, '../utils/generatedImageLinks':require('../src/utils/generatedImageLinks'), '../utils/imageTool':{tools:imageTool.tools,dispatch:(value,options)=>imageTool.dispatch(value,{...options,images:{enqueue:async(...args)=>{jobs.push(args);return {jobId:id,status:'pending'};}}})}, '../utils/clinicalMcpClient':{semanticSearch:async()=>({})}, '../utils/clinicalRetrieval':{normalizeMcpSearchResponse:()=>[{number:3,title:'Synthetic source',page:17,excerpt:'Synthetic reference'}],dedupeSources:s=>s, normalizeMcpMultimodalResponse:()=>[],cleanSourceExcerpt:s=>s,isVisualSourceQuery:()=>false,classifyAndRerankMultimodalResults:async()=>[]} }; const module = {exports:{}}; vm.runInNewContext(fs.readFileSync(file,'utf8'),{module,Buffer,console:{info(){},warn(){},error(){}},process:{env:{CLINICAL_ASSISTANT_MCP_WARMUP:'false'}},setTimeout(){},require:n=>{assert.ok(n in mocks,n);return mocks[n];}}); return async (path,body) => { const endpoint = module.exports.stack.find(l=>l.route?.path===path).route.stack.at(-1).handle; const response = {statusCode:200,events:'',status(s){this.statusCode=s;return this;},json(d){this.data=d;},setHeader(){},flushHeaders(){},write(t){this.events+=t;},end(){}}; await endpoint({user:{id:101},body},response);return response; }; } test('actual Clinical chat + fragmented stream send real tools to SDK, dispatch jobs, and preserve citation/body/source/page identity without regeneration', async () => { const body='Exact [3].\n\nSentence ending with'; // Old truncation heuristic would regenerate this. for (const streaming of [false,true]) { const {ai,requests}=loadAI('litellm',[ {choices:[{delta:{content:body,tool_calls:[{index:0,id:'one',type:'function',function:{name:'generate_image',arguments:'{"prompt":'}}]}}]}, {choices:[{delta:{tool_calls:[{index:0,function:{arguments:'"diagram"}'}}]},finish_reason:'tool_calls'}]} ],body); const jobs=[];const request=route('src/routes/clinicalAssistant.js',ai,jobs); const response=await request('/clinical-assistant/chat'+(streaming?'/stream':''),{message:' Create a clinical diagram for the precise current clinical findings 😀\n',history:[{role:'assistant',content:'Prior table [3].'}],idempotencyKey:'same-request'}); assert.equal(response.statusCode,200,JSON.stringify(response.data)); const result=streaming?JSON.parse(response.events.match(/event: done\ndata: (.*)/)[1]):response.data; assert.equal(result.answer,body);assert.equal(result.sources[0].number,3);assert.equal(result.sources[0].page,17);assert.equal(result.sources[0].title,'Synthetic source'); assert.equal(result.imageJobs[0].jobId,id);assert.equal(jobs.length,1);assert.equal(jobs[0][5].request,' Create a clinical diagram for the precise current clinical findings 😀\n');assert.equal(jobs[0][5].history[0].content,'Prior table [3].');assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image'); } }); test('actual Learning generate/refine use callable tools and bind their own workflow (not sidebar heuristics)', async()=>{ for (const [path,content,input] of [ ['/ai-generate','{"title":"Teaching","body":"
Exact body.
","questions":[]}',{topic:'Create a diagram',idempotencyKey:'gen'}], ['/ai-refine','Exact refined body.
',{content:'Prior body.
',instructions:'Include an image',idempotencyKey:'ref'}] ]) { const {ai,requests}=loadAI('litellm',undefined,content);const jobs=[]; const request=route('src/routes/learningAI.js',ai,jobs);const response=await request(path,input); assert.equal(response.statusCode,200,JSON.stringify(response.data));assert.equal(response.data.success,true);assert.equal(response.data.imageJobs[0].jobId,id); assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image');assert.equal(jobs[0][1],'learning_hub'); assert.equal(path==='/ai-refine'?response.data.refined:response.data.content.body,path==='/ai-refine'?input.content:'Exact body.
'); } }); test('Learning tool-only refinement retains every existing body/citation/page byte, including edge whitespace', async () => { const content = '\nExact [3, 1].
\n| 5 mg | page 19 [3] |
Original [3, 1].
| 5 mg | 19 [3] |
ALTERED [9].
'; const request=route('src/routes/learningAI.js',{callAI:async()=>({content:replacement,...(withTool?{toolCalls:[{id:'x',type:'function',function:{name:'generate_image',arguments:'{"prompt":"diagram"}'}}]}:{})})},jobs); const response=await request('/ai-refine',{content:original,instructions:withTool?'Include an image':'Shorten this text'}); assert.equal(response.statusCode,200);assert.equal(response.data.refined,withTool?original:replacement); assert.equal(response.data.bodyPreserved,withTool);assert.equal(jobs.length,withTool?1:0); } });